Русский
Русский
English
Статистика
Реклама

Высокая производительность

Перевод О появлении поддержки CUDA в WSL 2

05.07.2020 16:17:42 | Автор: admin
Компания Microsoft, откликаясь на многочисленные просьбы пользователей, представила в мае 2020 года на конференции Build новую возможность подсистемы Windows для Linux 2 (Windows Subsystem for Linux 2, WSL 2) поддержку видеоускорителей. Это позволит запускать в WSL 2 приложения, занимающиеся специализированными вычислениями. Поддержка GPU откроет дорогу профессиональным инструментам, поможет решать в WSL 2 задачи, которые в настоящее время можно решать только в Linux. Теперь подобные задачи можно будет решать и в Windows, пользуясь возможностями GPU.

Крайне важно тут и то, что в WSL приходит поддержка программно-аппаратной архитектуры параллельных вычислений NVIDIA CUDA.

Материал, перевод которого мы публикуем, подготовлен специалистами NVIDIA. Здесь речь пойдёт о том, чего можно ожидать от CUDA в Public Preview-версии WSL 2.


Запуск AI-фреймворков, используемых в Linux, в WSL 2-контейнерах

Что такое WSL?


WSL это возможность Windows 10, которая позволяет использовать инструменты командной строки Linux непосредственно в Windows без необходимости сталкиваться со сложностями применения конфигурации двойной загрузки. WSL представляет собой контейнеризованное окружение, которое тесно интегрировано с ОС Microsoft Windows. Это позволяет запускать Linux-приложения вместе с традиционными Windows-приложения и с современными приложениями, распространяемыми через Microsoft Store.

WSL это, преимущественно, инструмент для разработчиков. Если вы работаете над некими проектами в контейнерах Linux, это значит, что вы можете заниматься теми же делами локально, на Windows-компьютере, используя привычные инструменты Linux. Обычно, чтобы запустить подобные приложения на Windows, нужно потратить много времени на настройку системы, нужны какие-то сторонние фреймворки, библиотеки. Теперь, с выходом WSL 2, всё изменилось. Благодаря WSL 2 в мир Windows пришла полная поддержка ядра Linux.

WSL 2 и технология паравиртуализации GPU (GPU Paravirtualization, GPU-PV) позволили Microsoft вывести поддержку Linux в Windows на новый уровень, сделав возможным запуск вычислительных нагрузок, рассчитанных на GPU. Ниже мы подробнее поговорим о том, как выглядит использование GPU в WSL 2.

Если вас интересует тема поддержки видеоускорителей в WSL 2 взгляните на этот материал и на этот репозиторий.

CUDA в WSL


Для того чтобы воспользоваться возможностями GPU в WSL 2, необходимо, чтобы на компьютере был бы установлен видеодрайвер, поддерживающий Microsoft WDDM. Подобные драйверы создают производители видеокарт такие, как NVIDIA.

Технология CUDA позволяет заниматься разработкой программ для видеоускорителей NVIDIA. Эта технология поддерживается в WDDM, в Windows, уже многие годы. Новый контейнер WSL 2 от Microsoft даёт возможности по GPU-ускорению вычислений, которыми может воспользоваться технология CUDA, что позволяет выполнять в среде WSL программы, рассчитанные на CUDA. Подробности об этом можно узнать в руководстве пользователя по работе с CUDA в WSL.

Поддержка CUDA в WSL включена в драйверы NVIDIA, рассчитанные на WDDM 2.9. Эти драйверы достаточно просто установить в Windows. Драйверы пользовательского режима CUDA в WSL (libcuda.so) автоматически становятся доступными внутри контейнера, их может обнаружить загрузчик.

Команда NVIDIA, занимающаяся разработкой драйверов, добавила в драйвер CUDA поддержку WDDM и GPU-PV. Сделано это для того чтобы эти драйверы могли бы работать в среде Linux, запущенной на Windows. Эти драйверы всё ещё находятся в статусе Preview, их релиз состоится только тогда, кода состоится официальный релиз WSL с поддержкой GPU. Подробности о выпуске драйверов можно найти здесь.

На следующем рисунке показана схема подключения драйвера CUDA к WDDM внутри гостевой системы Linux.


WDDM-драйвер пользовательского режима с поддержкой CUDA, выполняющийся в гостевой системе Linux

Предположим, вы разработчик, который установил дистрибутив WSL на последнюю сборку Windows из Fast Ring (сборка 20149 или старше) Microsoft Windows Insider Program (WIP). Если вы переключились на WSL 2 и у вас есть GPU NVIDIA, вы можете испытать драйвер и запустить свой код, выполняющий GPU-вычисления, в WSL 2. Для этого достаточно установить драйвер в хост-системе Windows и открыть WSL-контейнер. Здесь вам, без дополнительных усилий, будет доступна возможность работы с приложениями, использующими CUDA. На следующем рисунке показано, как в WSL 2-контейнере выполняется TensorFlow-приложение, использующее возможности CUDA.


TensorFlow-контейнер, выполняющийся в WSL 2

То, что в WSL теперь доступна технология CUDA, позволяет выполнять в WSL приложения, которые раньше можно было выполнять только в обычном Linux-окружении.

NVIDIA всё ещё активно работает над этим проектом и вносит в него улучшения. Мы, кроме прочего, работаем над добавлением в WDDM API, которые раньше были рассчитаны исключительно на Linux. Это приведёт к тому, что в WSL, без дополнительных усилий со стороны пользователя, сможет работать всё больше и больше приложений.

Ещё один интересующий нас вопрос это производительность. Как уже было сказано, поддержка GPU в WSL 2 серьёзно использует технологию GPU-PV. Это может плохо повлиять на скорость выполнения небольших задач на GPU, в ситуациях, когда не будет использоваться конвейеризация. Прямо сейчас мы работаем в направлении как можно более сильного сокращения подобных эффектов.

NVML


В исходный пакет драйвера не включена технология NVML, мы стремимся это исправить, планируя добавить в WSL поддержку NVML и поддержку других библиотек.

Мы начали работу с основного драйвера CUDA, что позволит пользователям запускать большую часть существующих CUDA-приложений даже на ранней стадии появления поддержки CUDA в WSL. Но, как оказалось, некоторые контейнеры и приложения используют NVML для получения информации о GPU даже до загрузки CUDA. Именно поэтому добавление поддержки NVML в WSL это одна из наших первоочередных задач. Вполне возможно то, что скоро мы сможем поделиться хорошими новостями по поводу решения этой задачи.

GPU-контейнеры в WSL


В дополнение к поддержке в WSL 2 DirectX и CUDA, NVIDIA работает над добавлением в WSL 2 поддержки NVIDIA Container Toolkit (раньше эта технология называлась nvidia-docker2). Контейнеризованные GPU-приложения, которые дата-сайентисты создают в расчёте на запуск в локальной или облачной среде Linux, теперь могут, без внесения в них каких-либо изменений, запускаться в WSL 2, на компьютерах, работающих под управлением Windows.

Каких-то особых пакетов WSL для этого не требуется. Библиотека времени выполнения NVIDIA (libnvidia-container) может динамически обнаруживать библиотеку libdxcore и пользоваться ей в ситуации, когда код выполняется в WSL 2-среде с поддержкой GPU-ускорения. Это происходит автоматически, после установки пакетов Docker и NVIDIA Container Toolkit, так же, как и на Linux. Это позволяет, без дополнительных усилий, запускать в WSL 2 контейнеры, в которых используются возможности GPU.

Мы настоятельно рекомендуем тем, кто хочет пользоваться опцией --gpus, установить последнюю версию инструментов Docker (19.03 или свежее). Для того чтобы включить поддержку WSL 2, следуйте инструкциям для вашего дистрибутива Linux и установите самую свежую из доступных версий nvidia-container-toolkit.

Как это работает? Все задачи, характерные для WSL 2, решаются средствами библиотеки libnvidia-container. Теперь эта библиотека может, во время выполнения, обнаруживать присутствие libdxcore.so и использовать эту библиотеку для обнаружения всех GPU, видимых этому интерфейсу.

Если эти GPU нужно использовать в контейнере, то, с помощью libdxcore.so, выполняется обращение к месту хранения драйверов, к папке, которая содержит все библиотеки драйверов для хост-системы Windows и WSL 2. Библиотека libnvidia-container.so отвечает за настройку контейнера таким образом, чтобы можно было бы корректно обратиться к хранилищу драйверов. Эта же библиотека отвечает за настройку базовых библиотек, поддерживаемых WSL 2. Схема этого показана на следующем рисунке.


Схема обнаружения и отображения в контейнер хранилища драйверов, используемая libnvidia-container.so в WSL 2

Кроме того, это отличается от логики, используемой за пределами WSL. Этот процесс полностью абстрагирован с помощью libnvidia-container.so и он, для конечного пользователя, должен быть как можно прозрачнее. Одно из ограничений этой ранней версии заключается в невозможности выбора GPU в окружениях, в которых имеется несколько GPU. В контейнере всегда видны все GPU.

В WSL-контейнере можно запустить любые Linux-контейнеры NVIDIA, с которыми вы уже знакомы. NVIDIA поддерживает самые интересные инструменты и рабочие процессы, характерные для Linux и используемые профессионалами. Загрузите интересующий вас контейнер из NVIDIA NGC и испытайте его.

Сейчас мы расскажем о том, как запускать в WSL 2 контейнеры TensorFlow и N-body, рассчитанные на использование GPU NVIDIA для ускорения вычислений.

Запуск контейнера N-body


Установим Docker, воспользовавшись скриптом установки:

user@PCName:/mnt/c$ curl https://get.docker.com | sh

Установим NVIDIA Container Toolkit. Поддержка WSL 2 доступна, начиная с nvidia-docker2 v2.3 и с библиотеки времени выполнения libnvidia-container 1.2.0-rc.1.

Настроим репозитории stable и experimental и GPG-ключ. Изменения в коде времени выполнения, рассчитанные на поддержку WSL 2, доступны в экспериментальном репозитории.

user@PCName:/mnt/c$ distribution=$(. /etc/os-release;echo $ID$VERSION_ID)user@PCName:/mnt/c$ curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add -user@PCName:/mnt/c$ curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | sudo tee /etc/apt/sources.list.d/nvidia-docker.listuser@PCName:/mnt/c$ curl -s -L https://nvidia.github.io/libnvidia-container/experimental/$distribution/libnvidia-container-experimental.list | sudo tee /etc/apt/sources.list.d/libnvidia-container-experimental.list

Установим пакеты времени выполнения NVIDIA и их зависимости:

user@PCName:/mnt/c$ sudo apt-get updateuser@PCName:/mnt/c$ sudo apt-get install -y nvidia-docker2

Откроем WSL-контейнер и запустим в нём демон Docker. Если всё сделано правильно после этого можно будет увидеть служебные сообщения dockerd.

user@PCName:/mnt/c$ sudo dockerd


Запуск демона Docker

В другом окне WSL загрузим и запустим контейнер симуляции N-body. Нужно, чтобы у пользователя, выполняющего эту задачу, было бы достаточно полномочий для загрузки контейнера. Следующие команды может потребоваться запустить с использованием sudo. В выводимых данных можно увидеть сведения о GPU.

user@PCName:/mnt/c$ docker run --gpus all nvcr.io/nvidia/k8s/cuda-sample:nbody nbody -gpu -benchmark


Запуск контейнера N-body

Запуск контейнера TensorFlow


Испытаем в Docker, в среде WSL 2, ещё один популярный контейнер TensorFlow.

Загрузим Docker-образ TensorFlow. Для того чтобы избежать проблем с подключением к Docker, выполним следующую команду в режиме sudo:

user@PCName:/mnt/c$ docker pull tensorflow/tensorflow:latest-gpu-py3

Сохраним немного изменённую версию кода из 15 урока руководства по TensorFlow, посвящённого использованию GPU, на диск C хост-системы. Этот диск, по умолчанию, монтируется в контейнере WSL 2 как /mnt/c.

user@PCName:/mnt/c$ vi ./matmul.pyimport sysimport numpy as npimport tensorflow as tffrom datetime import datetimedevice_name = sys.argv[1] # Choose device from cmd line. Options: gpu or cpushape = (int(sys.argv[2]), int(sys.argv[2]))if device_name == "gpu":device_name = "/gpu:0"else:device_name = "/cpu:0"tf.compat.v1.disable_eager_execution()with tf.device(device_name):random_matrix = tf.random.uniform(shape=shape, minval=0, maxval=1)dot_operation = tf.matmul(random_matrix, tf.transpose(random_matrix))sum_operation = tf.reduce_sum(dot_operation)startTime = datetime.now()with tf.compat.v1.Session(config=tf.compat.v1.ConfigProto(log_device_placement=True)) as session:result = session.run(sum_operation)print(result)# Вывод результатовprint("Shape:", shape, "Device:", device_name)print("Time taken:", datetime.now() - startTime)

Ниже показаны результаты выполнения этого скрипта, запущенного со смонтированного в контейнере диска C. Скрипт выполнялся, сначала, с использованием GPU, а потом с использованием CPU. Для удобства объём представленных здесь выходных данных сокращён.

user@PCName:/mnt/c$ docker run --runtime=nvidia --rm -ti -v "${PWD}:/mnt/c" tensorflow/tensorflow:latest-gpu-jupyter python /mnt/c/matmul.py gpu 20000


Результаты выполнения скрипта matmul.py

При использовании GPU в WSL 2-контейнере наблюдается значительное ускорение выполнения кода в сравнении с его выполнением на CPU.

Проведём ещё один эксперимент, рассчитанный на исследование производительности GPU-вычислений. Речь идёт о коде из руководства по Jupyter Notebook. После запуска контейнера вы должны увидеть ссылку на сервер Jupyter Notebook.

user@PCName:/mnt/c$ docker run -it --gpus all -p 8888:8888 tensorflow/tensorflow:latest-gpu-py3-jupyter


Запуск Jupyter Notebook

Теперь у вас должна появиться возможность запускать демонстрационные примеры в среде Jupyter Notebook. Обратите внимание на то, то, что для подключения к Jupyter Notebook с использованием браузера Microsoft Edge, нужно, вместо 127.0.0.1, использовать localhost.

Перейдите в tensorflow-tutorials и запустите блокнот classification.ipynb.

Для того чтобы увидеть результаты ускорения вычислений с помощью GPU, перейдите в меню Cell, выберите Run All и посмотрите журнал в WSL 2-контейнере Jupyter Notebook.


Журнал Jupyter Notebook

Этот демонстрационный пример, да и некоторые другие в данном контейнере, позволяют увидеть проблемы со слоем виртуализации, относящиеся к неоправданно высокой дополнительной нагрузке на систему при решении небольших задач. Выше мы уже говорили об этом. Так как мы запускаем тут очень маленькие учебные модели, время их выполнения на GPU меньше времени, необходимого на решение задач синхронизации. При решении таких вот игрушечных задач в WSL 2, CPU может оказаться эффективнее GPU. Мы занимаемся решением этой проблемы, стремясь ограничить её проявления лишь совсем небольшими рабочими нагрузками, к которым не применяется конвейеризация.

Обзор WSL


Для того чтобы понять то, как поддержка GPU была добавлена в WSL 2, сейчас мы поговорим о том, что собой представляет запуск Linux на Windows, и о том, как контейнеры видят аппаратное обеспечение.

Компания Microsoft представила технологию WSL на конференции Build в 2016 году. Эта технология быстро нашла широкое применение и стала популярной в среде Linux-разработчиков, которым нужно было запускать Windows-приложения, вроде Office, вместе с инструментами разработки для Linux и соответствующими программами.

Система WSL 1 позволяла запускать немодифицированные исполняемые файлы Linux. Однако здесь использовался слой эмуляции ядра Linux, который был реализован в виде подсистемы ядра NT. Эта подсистема обрабатывала вызовы, поступающие от Linux-приложений, перенаправляя их соответствующим механизмам Windows 10.

Система WSL 1 была полезным инструментом, но она не была совместима со всеми Linux-приложениям, так как ей требовалось эмулировать абсолютно все системные вызовы Linux. Кроме того, медленными были операции по работе с файловой системой, что приводило к недопустимо низкой производительности некоторых приложений.

Учитывая это, Microsoft решила пойти другим путём и выпустила WSL 2 новую версию WSL. Контейнеры WSL 2 выполняют полные Linux-дистрибутивы в виртуализованном окружении, но при этом используют все полезные возможности новой системы контейнеризации Windows 10.

В то время как WSL 2 использует Hyper-V-сервисы Windows 10, это не традиционная виртуальная машина, а, скорее, легковесный вспомогательный механизм виртуализации. Этот механизм отвечает за управление виртуальной памятью, связанной с физической памятью, позволяя WSL 2-контейнерам динамически выделять память, обращаясь к хост-системе Windows.

Среди основных целей создания WSL 2 можно отметить увеличение производительности работы с файловой системой и обеспечение совместимости со всеми системными вызовами. Кроме того, WSL 2 создавали, стремясь улучшить уровень интеграции WSL и Windows. Это позволяет удобно работать с Linux-системой, выполняемой в контейнере, пользуясь средствами командной строки Windows. Это, кроме того, повышает удобство работы с файловой системой хоста, автоматически монтируемой в выбранные директории файловой системы контейнера.

WSL 2 была представлена в Windows Insider Program в виде Preview-возможности и была выпущена в самом свежем обновлении Windows 10, в версии 2004.

В WSL 2 из самой свежей версии Windows внесено ещё больше улучшений, которые затрагивают много всего от сетевых стеков, до базовых VHD-механизмов системы хранения данных. Описание всех новых возможностей WSL 2 выходит за рамки этого материала. Подробнее о них можно узнать на этой странице, где приводится сравнение WSL 2 и WSL 1.

Linux-ядро WSL 2


Ядро Linux, применяемое в WSL 2, собрано Microsoft на основе самой свежей стабильной ветки, с использованием исходного кода, доступного на kernel.org. Это ядро было специально настроено для WSL 2, оптимизировано с точки зрения размеров и производительности с целью обеспечения работы Linux в среде Windows. Ядро поддерживается через механизм Windows Update. Это значит, что пользователю не нужно заботиться о том, чтобы загружать последние обновления безопасности и улучшения ядра. Всё это делается автоматически.

Microsoft поддерживает в WSL несколько дистрибутивов Linux. Компания, следуя правилам опенсорс-сообщества, опубликовала в GitHub-репозитории WSL2-Linux-Kernel исходный код ядра WSL 2 с модификациями, необходимыми для интеграции с Windows 10.

Поддержка GPU в WSL


Разработчики Microsoft добавили в WSL 2-контейнеры поддержку реальных GPU с использованием технологии GPU-PV. Здесь графическое ядро операционной системы (dxgkrnl) маршалирует драйверу режима ядра, который находится на хосте, вызовы от компонентов пользовательского режима, выполняемых в гостевой виртуальной машине.

Компания Microsoft разработала эту технологию в виде возможности WDDM, с момента её появления вышло уже несколько релизов Windows. Эта работа была проведена с привлечением независимых производителей аппаратного обеспечения (Independent Hardware Vendor, IHV). Графические драйверы NVIDIA поддерживали GPU-PV начиная с ранних дней появления этой технологии в Preview-версиях продуктов, доступных в Windows Insider Program. Все GPU NVIDIA, поддерживаемые в настоящий момент, могут быть доступны ОС Windows, выполняемой в гостевом режиме, в виртуальной машине, использующей Hyper-V.

Для того чтобы в WSL 2 можно было бы пользоваться возможностями GPU-PV, Microsoft пришлось создать базу своего графического фреймворка для гостевой системы Linux: WDDM с поддержкой протокола GPU-PV. Новый драйвер Microsoft находится за dxgkrnl, за системой, отвечающей за поддержку WDDM в Linux. Код драйвера можно найти в репозитории WSL2-Linux-Kernel.

Ожидается, что dxgkrnl обеспечит поддержку GPU-ускорения в контейнерах WSL 2 в WDDM 2.9. Microsoft говорит о том, что dxgkrnl это GPU-драйвер Linux, основанный на протоколе GPU-PV, и о том, что у него нет ничего общего с Windows-драйвером, имеющим похожее имя.

В настоящее время вы можете загрузить Preview-версию драйвера NVIDIA WDDM 2.9. В ближайшие несколько месяцев этот драйвер будет распространяться через Windows Update в WIP-версии Windows, что делает ненужными ручную загрузку и установку драйвера.

Основные сведения о GPU-PV


Драйвер dxgkrnl делает доступным, в пользовательском режиме гостевой системы Linux, новое устройство /dev/dxg. Сервисный слой ядра D3DKMT, который был доступен в Windows, тоже был портирован, как часть библиотеки dxcore, на Linux. Он взаимодействует с dxgkrnl, используя набор частных IOCTL-вызовов.

Гостевая Linux-версия dxgkrnl подключаются к ядру dxg на Windows-хосте, используя несколько каналов шины VM. Ядро dxg на хосте обрабатывает то, что ему приходит от Linux-процесса, так же, как то, что приходит от обычных Windows-приложений, использующих WDDM. А именно, ядро dxg отправляет то, что получило, KMD (Kernel Mode Driver, драйверу режима ядра, уникальному для каждого HIV). Драйвер режима ядра подготавливает то, что получил, для отправки аппаратному графическому ускорителю. На следующем рисунке показана упрощённая схема взаимодействия Linux-устройства /dev/dxg и KMD.


Упрощённая схема, иллюстрирующая то, как компоненты Windows-хоста обеспечивают работу устройства dxg в гостевой системе Linux

Если говорить об обеспечении подобной схемы работы в гостевых системах Windows, то можно сказать, что драйверы NVIDIA поддерживают GPU-PV в Windows 10 уже довольно давно. GPU NVIDIA могут быть использованы для ускорения вычислений и вывода графики во всех Windows 10-приложениях, использующих слой виртуализации Microsoft. Использование GPU-PV позволяет и работать с vGPU. Вот несколько примеров подобных приложений:


Вот как выглядит запуск DirectX-приложения в контейнере Windows Sandbox с применением видеоускорителя NVIDIA GeForce GTX 1070.


В контейнере Windows Sandbox ускорение графики выполняется средствами NVIDIA GeForce GTX 1070

Поддержка пользовательского режима


Для того чтобы добавить в WSL поддержку вывода графики, соответствующая команда разработчиков из Microsoft, кроме того, портировала на Linux компонент пользовательского режима dxcore.

Библиотека dxcore предоставляет API, который позволяет получать сведения об имеющихся в системе графических адаптерах, совместимых с WDDM. Эту библиотеку задумывали как кросс-платформенную низкоуровневую замену для средства работы с DXGI-адаптерами в Windows и Linux. Библиотека, кроме того, абстрагирует доступ к сервисам dxgkrnl (IOCTL-вызовы в Linux и GDI-вызовы в Windows), используя слой API D3DKMT, который используется CUDA и другими компонентами пользовательского режима, полагающимися на поддержку WDDM в WSL.

По сведениям Microsoft, библиотека dxcore (libdxcore.so) будет доступна и в Windows, и в Linux. NVIDIA планирует добавить в драйвер поддержку DirectX 12 и API CUDA. Эти дополнения нацелены на новые возможности WSL, доступные благодаря WDDM 2.9. Обе библиотеки, представляющие API, будут подключены к dxcore для того чтобы они могли бы давать dxg указания по поводу маршалирования их запросов к KMD на хост-системе.

Попробуйте новые возможности WSL 2


Хотите использовать свой Windows-компьютер для решения настоящих задач из сфер машинного обучения и искусственного интеллекта, и при этом пользоваться всеми удобствами Linux-окружения? Если так, то поддержка CUDA в WSL даёт вам отличную возможность это сделать. Среда WSL это то место, где Docker-контейнеры CUDA показали себя как самое популярное среди дата-сайентистов вычислительное окружение.

  • Для того чтобы получить доступ к Preview-версии WSL 2 с поддержкой GPU-ускорения, вы можете присоединиться к Windows Insider Program.
  • Загрузите свежие драйверы NVIDIA, установите их и попробуйте запустить в WSL 2 какой-нибудь CUDA-контейнер.

Здесь можно узнать подробности о применении технологии CUDA в WSL. Здесь, на форуме, посвящённом CUDA и WSL, вы можете поделиться с нами вашими впечатлениями, наблюдениями и идеями об этих технологиях.

А вы уже пробовали CUDA в WSL 2?

Подробнее..

Перевод Память в JavaScript без утечек

22.06.2020 22:22:51 | Автор: admin
image


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

Вступление


JavaScript это язык очень высокого уровня. Таким образом, большинство разработчиков не задумываются о том, как данные хранятся в памяти. В этой статье мы рассмотрим, как данные хранятся в памяти, как они влияют на процессор и память и как способ, которым вы распространяете данные в JS и обращаетесь к ним, влияет на производительность.

Любовный треугольник


Когда компьютеру необходимо выполнить некоторые вычисления, процессору (ЦП) нужны данные для обработки. Таким образом, в соответствии с поставленной задачей он отправляет в память запрос на выборку данных через шину.

Это выглядит так:

image

Так что это наш романтический треугольник Процессор -> Шина -> Память

Поддерживать здоровые отношения в трио сложно


Процессор намного быстрее, чем память. Таким образом, этот процесс Процессор -> Шина -> Память -> Шина -> Процессор тратит время на вычисления. Пока просматривается память, процессор бездействует.

Для предотвращения простоя, в систему был добавлен кеш. Мы не будем вдаваться в подробности что такое кэш или какие есть типы кэша, но достаточно сказать, что кэш это внутренняя память процессора.

Когда ЦП получает команду на исполнение, он сначала ищет данные в кэше, а если данных там нет, он отправляет запрос через шину.

Шина, в свою очередь, передает запрашиваемые данные плюс условную часть памяти и сохраняет их в кеше для быстрого обращения.

Таким образом, у ЦП будет меньше недовольства на то, насколько медленная память и, следовательно, у ЦП будет меньше времени простоя.

Ссоры являются частью любых отношений


Проблема, которая может возникнуть особенно когда мы имеем дело с обработкой огромных объемов данных это явление, называемое промах кэша (cache miss).

Промах кэша означает, что во время вычислений, ЦП обнаруживает, что не имеет необходимых данных в кэше, и ему нужно запрашивать эти данные по обычным каналам вы помните про очееееень медленнную память.

image

Иллюстрация промаха кеша. Данные из массива обрабатываются, но данные за пределами лимита кэша также запрашиваются для вычисления и это создает промах кэша.

Хорошо, но я JavaScript разработчик, почему меня это должно волновать?


Хороший вопрос. В большинстве случаев вы об этом не задумываетесь. Однако сегодня, когда все больше и больше данных проходит через сервера написанные на Node.js, у вас больше шансов встретить проблему промаха кэша при переборе больших наборов данных.

Я поверю в это, когда увижу это!!!

Справедливо. Давайте посмотрим на пример.

Вот класс под названием Boom.

class Boom {  constructor(id) {    this.id = id;  }  setPosition(x, y) {    this.x = x;    this.y = y;  }}

Этот класс (Boom) имеет всего 3 свойства id, x и y.

Теперь давайте создадим метод, который заполняет x и y.

Давайте зададим данные:

const ROWS = 1000;const COLS = 1000;const repeats = 100;const arr = new Array(ROWS * COLS).fill(0).map((a, i) => new Boom(i));

Теперь мы будем использовать эти данные в методе:

function localAccess() {  for (let i = 0; i < ROWS; i++) {    for (let j = 0; j < COLS; j++) {      arr[i * ROWS + j].x = 0;    }  }}

Что делает localAccess, так это линейно проходит по массиву и устанавливает x равным 0.

Если мы повторим эту функцию 100 раз (посмотрите на константу repeats), мы можем измерить, сколько времени потребуется для выполнения:

function repeat(cb, type) {  console.log(`%c Started data ${type}`, 'color: red');  const start = performance.now();  for (let i = 0; i < repeats; i++) {    cb();  }  const end = performance.now();  console.log('Finished data locality test run in ', ((end-start) / 1000).toFixed(4), ' seconds');  return end-start;}repeat(localAccess, 'Local');

Вывод журнала:

image

Цена за промах кэша


Теперь, согласно тому, что мы узнали выше, мы можем рассмотреть проблему промаха кэша, если обработаем данные, которые находятся далеко во время итерации. Удаленные данные это данные, которых нет в соседнем индексе. Вот функция для этого:

function farAccess() {  for (let i = 0; i < COLS; i++) {    for (let j = 0; j < ROWS; j++) {      arr[j * ROWS + i].x = 0;    }  }}

Здесь происходит то, что на каждой итерации мы обращаемся к индексу, который находится на расстоянии ROWS от последней итерации. Поэтому, если ROWS равен 1000 (как в нашем случае), мы получаем следующую итерацию: [0, 1000, 2000,, 1, 1001, 2001,].

Давайте добавим это в наш тест скорости:

repeat(localAccess, 'Local');setTimeout(() => {  repeat(farAccess, 'Non Local');}, 2000);

И вот конечный результат:

image

Нелокальная итерация была почти в 4 раза медленнее. Эта разница будет расти с увеличением данных. Это происходит из-за того, что процессор простаивает из-за промаха кэша.

Так какую цену вы платите? Все зависит от размера ваших данных.

Хорошо, я клянусь, я никогда этого не сделаю!


Возможно, вы об этом не думаете, но есть случаи, когда вы хотите получить доступ к массиву с некоторой логикой, которая не является линейной (например, 1,2,3,4,5) или не является условной (например, for(let i = 0; i <n; i + = 1000)).

Например, вы получаете данные из сервиса или БД, и вам нужно обрабатывать их, отсортировывать или отфильтровывать с помощью сложной логики. Это может привести к ситуации аналогичной той, что была показана в функции farAccess.

Вот иллюстрация, которая (надеюсь) прояснит ситуацию:

image

Исходные данные в памяти vs отсортированные данные в памяти. Числа обозначают индексы объектов в исходном массиве.

Глядя на изображение выше, мы видим данные в том виде, в каком они хранятся в памяти (верхняя серая полоса). Ниже мы видим массив, который был создан, когда данные поступили с сервера. Наконец, мы видим отсортированный массив, который содержит ссылки на объекты, хранящиеся в различных позициях в памяти.

Таким образом, итерация по отсортированному массиву может привести к множественным промахам в кэше, как показано в примере выше.
Обратите внимание, что этот пример для небольшого массива. Промахи в кеше актуальны для гораздо больших данных.
В мире, где вам нужны плавные анимации на фронтенде и где вы можете загружать каждую миллисекунду процессорного времени на бекенде это может стать критическим.

О нет! Все потеряно!!!


Нет, не совсем.

Существуют различные решения этой проблемы, но теперь, когда вы знаете причину такого падения производительности, вы, вероятно, можете придумать решение самостоятельно. Вам просто нужно хранить данные, которые обрабатываются как можно ближе в памяти.
Такая техника называется шаблоном проектирования Data Locality.
Давайте продолжим наш пример. Предположим, что в нашем приложении наиболее распространенным процессом является обработка данных с помощью логики, показанной в функции farAccess. Мы хотели бы оптимизировать данные, чтобы они работали быстро в наиболее распространенном цикле for.

Мы организуем такие данные:

const diffArr = new Array(ROWS * COLS).fill(0);for (let col = 0; col < COLS; col++) {  for (let row = 0; row < ROWS; row++) {    diffArr[row * ROWS + col] = arr[col * COLS + row];  }}

Так что теперь, в diffArr, объекты, которые находятся в индексах [0,1,2,] в исходном массиве, теперь установлены следующим образом [0,1000,2000,, 1, 1001, 2001,, 2, 1002, 2002, ...]. Цифры обозначают индекс объекта. Это имитирует сортировку массива, что является одним из способов реализации шаблона проектирования Data Locality.

Чтобы легко это проверить, мы немного изменим нашу функцию farAccess, чтобы получить кастомный массив:

function farAccess(array) {  let data = arr;  if (array) {    data = array;  }  for (let i = 0; i < COLS; i++) {    for (let j = 0; j < ROWS; j++) {      data[j * ROWS + i].x = 0;    }  }}

А теперь добавьте сценарий к нашему тесту:

repeat(localAccess, 'Local');setTimeout(() => {  repeat(farAccess, 'Non Local')  setTimeout(() => {    repeat(() => farAccess(diffArr), 'Non Local Sorted')  }, 2000);}, 2000);

Мы запускаем это, и мы получаем:

image

И вуаля! мы оптимизировали наши данные, чтобы они соответствовали более распространенному подходу к ним.

Полный пример приведен тут.

Вывод


В этой статье мы продемонстрировали шаблон проектирования Data Locality. По сути, мы показали, что доступ к структуре данных способом, для которого она не оптимизирована, может снизить производительность. Затем мы оптимизировали данные в соответствии с нашим способом их обработки и увидели, как они улучшили производительность.

Шаблон проектирования Data Locality распространен в сфере разработки игр, где часто приходилось сталкиваться с множеством сущностей, которые повторяются много раз. В приведенных выше результатах мы видим, что локальность данных по-прежнему имеет значение даже в языках высокого уровня, таких как JavaScript, а не только в играх.

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

image

Узнайте подробности, как получить востребованную профессию с нуля или Level Up по навыкам и зарплате, пройдя платные онлайн-курсы SkillFactory:



Читать еще


Подробнее..

Горшочек, вари серверный ARM-чип Marvell ThunderX3 с 96 ядрами и SMT4 для 384 потоков

30.06.2020 20:09:15 | Автор: admin

Недавно мы публиковали новость о 128-ядерном ARM-процессоре Altra Max. Также на Хабре упоминали серверные ARM-чипы, которые использует компания Amazon. Но, как оказалось, серверные процессоры c архитектурой ARM выпускают и другие компании.

Так, еще в конце марта этого года был анонсирован процессор Marvell ThunderX3, это новое поколение серверных чипов от компании Marvell. Производитель увеличил количество ядер в своих процессорах с 32 до 96, оставив поддержку SMT4, которая дает возможность обрабатывать четыре потока одним ядром. Соответственно, такой чип способен обрабатывать 384 потока.


По словам представителей компании, SMT4 гарантия того, что конвейеры отдельных ядер нагружаются эффективно. Новые процессоры при этом способны отлично работать не только в условиях многопоточных, но и однопоточных вычислений. Что касается односокетной конфигурации, то здесь поддерживаются 64 линии PCI Express 4.0 lanes, в двухсокетной число линий PCIe 4.0 увеличено до 128. В случае двухсокетных конфигураций процессоры соединяются через 16 линий PCI Express 4.0.


В отличие от прочих поставщиков ARM-чипов, предназначенных для серверов, компания Marvell использует не ARM Neoverse N1 архитектуру, а кастомную ARM8.3+. Информации о подсистеме кешей пока нет, но известно, что уровень латентности постоянен и не зависит от взаимного расположения ядер. А подсистема памяти аналогична Graviton2 и не включает восьмиканальный контроллер DDR4 с поддержкой частот до 3200 МГц.

Компания выпустит несколько вариантов ThunderX3 с теплопакетами от 100 до 240 Вт. По словам разработчиков, энергоэффективность новинки на треть превосходит AMD Rome (EPYC 2 поколения). Техпроцесс новинки TSMC 7 нм.


Источник. Двухсокетная конфигурация с процессорами предыдущего поколения Thunder X2.

Производительность чипа на 25% выше, чем у процессоров ThunderX2 предыдущего поколения. Это если говорить о многопоточном режиме работы. В однопоточном производительность увеличена на 60%. При этом с целочисленными операциями новинка справляется в три раза быстрее представителей предыдущего поколения, а с вычислениями с плавающей запятой в пять раз.


Новые чипы будут использоваться для решения задач по обработке крупных массивов данных. Благодаря особенностям чипов они позволят запускать больше виртуальных машин в случае однопроцессорных систем.

Судя по результатам тестов, новые чипы по ряду показателей обгоняют конкурентов от Intel и AMD. Но это внутрикорпоративные испытания, к которым, как известно, стоит относиться с осторожностью.

Процессоры все еще недоступны для сторонних экспертов, так что результатов независимых тестов пока нет. Выпустить новые процессоры компания собирается к концу 2020 года. Дополнительная информация будет предоставлена в августе. Ну уже сейчас компания заявила, что через два года выпустит новое поколение своих серверных процессоров.
Хотите попробовать сервер на ARM-процессоре уже сейчас?

Добро пожаловать в Selectel Lab!

Подробнее..

Что нужно знать об архитектуре ClickHouse, чтобы его эффективно использовать. Алексей Зателепин (2018г)

04.07.2020 16:09:21 | Автор: admin

ClickHouse высокопроизводительная аналитическая база данных с открытыми исходниками, разработанная в Яндексе. Изначально ClickHouse создавался для задач Яндекс.Метрики, но постепенно нашёл множество применений как внутри Яндекса, так и в других компаниях. Я расскажу, как ClickHouse устроен внутри с акцентом на то, какие у выбранной архитектуры следствия с точки зрения прикладного разработчика.


Будут затронуты следующие темы:


  • Как ClickHouse хранит данные на диске и выполняет запрос, почему такой способ хранения позволяет на несколько порядков ускорить аналитические запросы, но плохо подходит для OLTP и key-value нагрузки.
  • Как устроена репликация и шардирование, как добиться линейного масштабирования и что делать с eventual consistency.
  • Как диагностировать проблемы на production-кластере ClickHouse.


Видео:



Идея доклада довольно проста: что если каким-то инструментом пользуешься или собираешься пользоваться, то желательно хотя бы в общих чертах представлять, что там внутри происходит, чтобы каких-то неправильных решений избежать и принять правильное решение. Вот такое понимание про ClickHouse постараюсь сегодня донести.



Я ClickHouse занимаюсь недавно. До этого я несколько лет работал в Яндекс.Картах. Был прикладным разработчиком. Много там работал с базами данных, с Postgres, поэтому я еще вирусом ClickHouse не сильно заражен, я еще помню, что такое быть прикладным разработчиком. Но, в принципе, уже все довольно хорошо понимаю.



Начнем сразу с задач. ClickHouse это не база специального назначения, везде ее пихать не надо, но спектр применения достаточно широкий. Он выражается вот такой формулой.


У нас есть некие события. Они постоянно приходят. Я тут выписал какие-то примеры. Вот Яндекс.Метрика это сервис, для которого ClickHouse изначально создавался.


  • Это какие-то действия пользователя на сайте.
  • Реклама.
  • Финансовые транзакции, покупки в магазинах.
  • И DNS-запросы.

Что мы хотим с этими событиями сделать? Мы хотим о них сохранить информацию и что-то о них понять потом, т. е. построить какие-то отчеты, аналитику, потом на них посмотреть и что-то понять.



Но любая система, которая эти задачи решает, имеет идеология. Т. е. что важно было для создателей этой системы, чем они готовы были поступиться, чем они готовы были пожертвовать.


Для ClickHouse самое важное:


  • Это интерактивные запросы. Что это такое? Это выполнение за секунды, а лучше меньше, чем за секунду. Почему это важно? Во-первых, когда Яндекс.Метрика показывает отчет, пользователь не будет ждать, если он загружается больше секунды. Но даже если вы, как аналитик, работаете с ClickHouse, с базой данной, то очень хорошо, если ответы на запросы тут же приходят. Вы тогда их можете много задать. Вы можете не отвлекаться. Вы можете погрузиться в работу с данными. Это другое качество работы.
  • Язык запросов у нас SQL. Это тоже и плюсы, и минусы. Плюсы в том, что SQL декларативный и поэтому простой запрос можно очень хорошо оптимизировать, т. е. очень оптимально выполнить. Но SQL не очень гибкий. Т. е. произвольную трансформацию данных с помощью SQL не задать, поэтому там у нас куча каких-то расширений, очень много функций дополнительных. А преимущество в том, что SQL все аналитики знают, это очень популярный язык.
  • Стараемся ничего заранее не агрегировать. Т. е. когда такую систему делаешь, то очень велик соблазн сначала подумать о том, какие отчеты нужны. Подумать, что у меня события будут поступать, я их буду потихоньку агрегировать и нужно будет показать отчет, я быстренько вот это все покажу. Но есть проблема такого подхода. Если вы два события слили вместе, то вы уже их ни в каком отчете больше не различите. Они у вас вместе. Поэтому чтобы сохранить гибкость, стараемся всегда хранить индивидуальные события и заранее ничего не агрегировать.
  • Еще один важный пункт, который требуется от прикладного разработчика, который работает с ClickHouse. Нужно заранее понять, какие есть атрибуты события. Нужно их самому выделить, вычленить и уже эти атрибуты запихивать в ClickHouse, т. е. какие-то json в свободной форме или текстовые blob, которые вы просто берете и пихаете, и надеетесь потом распарсить. Так лучше не делать, иначе у вас интерактивных запросов не будет.


Возьмем пример достаточно простой. Представим, что мы делаем клон Яндекс.Метрики, систему веб-аналитики. У нас есть счетчик, который мы на сайт ставим. Он идентифицируется колонкой CounterID. У нас есть таблица hits, в которую мы складываем просмотры страниц. И есть еще колонка Referer, и еще что-то. Т. е. куча всего, там 100 атрибутов.


Очень простой запрос делаем. Берем и группируем по Referer, считаем count, сортируем по count. И первые 10 результатов показываем.



Запрос нужно выполнить быстро. Как это сделать?


Во-первых, нужно очень быстро прочитать:


  • Самое простое, что тут надо сделать, это столбцовая организация. Т. е. храним данные по столбцам. Это нам позволит загрузить только нужные столбцы. В этом запросе это: ConterID, Date, Referrer. Как я уже сказал, их может быть 100. Естественно, если мы их все будем загружать, это все очень сильно нас затормозит.
  • Поскольку данные у нас в память, наверное, не помещаются, нам нужно читать локально. Конечно, мы не хотим читать всю таблицу, поэтому нам нужен индекс. Но даже если мы читаем эту маленькую часть, которая нам нужна, нам нужно локальное чтение. Мы не можем по диску прыгать и искать данные, которые нам нужны для выполнения запроса.
  • И обязательно нужно данные сжимать. Они в несколько раз сжимаются и пропускную способность диска очень сильно экономят.


И после того, как мы данные прочитали, нам нужно их очень быстро обработать. В ClickHouse много чего для этого делается:


  • Самое главное, что он их обрабатывает блоками. Что такое блок? Блок это небольшая часть таблицы, размером где-то в несколько тысяч строк. Почему это важно? Потому что ClickHouse это интерпретатор. Все знают, что интерпретаторы это очень медленно. Но если мы overhead размажем на несколько тысяч строк, то он будет незаметен. Зато это нам позволит применить SIMD инструкции. И для кэша процессора это очень хорошо, потому что если мы блок подняли в кэш, там его обрабатываем, то это будет гораздо быстрее, чем если он куда-то в память будет проваливаться.
  • И очень много низкоуровневых оптимизаций. Я про это не буду говорить.


Так же, как и в обычных классических БД мы выбираем, смотрим, какие условия будут в большинстве запросов. В нашей системе веб-аналитике, скорее всего, это будет счетчик. Хозяин счетчика будет приходить и смотреть отчеты. А также дата, т. е. он будет смотреть отчеты за какой-то период времени. Или, может быть, он за все время существования захочет посмотреть, поэтому такой индекс CounterID, Date.


Как мы можем проверить, что он подойдет? Сортируем таблицу по CounterID, Date и смотрим наши строки, которые нам нужны, они занимают вот такую небольшую область. Это значит, что индекс подойдет. Он будет сильно ускорять.


Но есть у ClickHouse индекса и особенности в отличие от привычных индексов. Во-первых, таблица будет упорядочена по ключу физически. В других системах еще называют кластер индекс. Это значит, что такой индекс на таблицу может быть только один. Второе, несмотря на то, что мы его называем первичный ключ, он не обеспечивает уникальности. Об этом нужно помнить.



Первое, что нужно понимать, то, что он разреженный, т. е. он не индексирует каждую строку в таблице, а он индексирует каждую десятитысячную строку. Физически он представляет собой значение выражения первичного ключа, которое записано для каждой 8 192 строки. По умолчанию такое значение мы предлагаем. Оно, в принципе, хорошо работает.


И когда у нас этот индекс есть, то при выполнении запроса, что мы делаем? Мы должны выбрать строки, которые нам могут пригодиться для выполнения запроса. В данном случае нам нужен счетчик 1234 и дата с 31 мая. А тут только есть запись на 23 мая. Это значит, что мы, начиная с этой даты, все должны прочитать. И до записи, счетчик которой начинается уже 1235. Получается, что мы будем читать немножко больше записей, чем нужно. И для аналитических задач, когда вам много нужно строк прочитать это не страшно. Но если вам нужна какая-то одна строка, то работать все будет не так хорошо. Чтобы найти одну строку, вам придется 8 000 прочитать.


Столбцы, в которых лежат данные, упорядочены по тому же выражению первичного клича. И теперь из них нужно составить вот этот блок, который будет потом в ClickHouse обрабатываться. Для этого есть вот такие файлы, которые мы называем файлы засечек, которые содержат указатели на значение, соответствующее строкам первичного ключа.


Как происходит чтение? Мы понимаем между какими значениями первичного ключа находятся интересующие нас строки по файлам засечек. В каждом столбце понимаем, что нам читать. Читаем. И дальше собираем блок. И дальше он пошел по конвейеру запроса.


Что тут важно? Key-Value сценарий будет плохо работать. У меня здесь светло-серым обозначено то, что мы прочитаем, и темно-серым то, что нам. И вполне может быть, что вам нужна будет одна строка, а вы прочитаете много.


И если у вас значения в столбце достаточно большие, например, 100 байт, а может, быть пара килобайт, то одна засечка, которую вы читаете, может занимать довольно много. Поэтому если у вас значения в столбцах большие, то, может быть, имеет смысл вот это значение чуть-чуть уменьшить.



Я сказал, что таблица упорядочена, но не рассказал, как это сделать. А нам очень хочется, чтобы данные, которые поступают в ClickHouse, тут же были доступны для отчетов. Т. е. после insert секунда прошла, и вы уже их видите в своих отчетах.


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


Как это сделать? В ClickHouse прилагается вот такое решение. Движок таблицы MergeTree. Идея примерно такая же, как у LSM дерева. Т. е. у нас есть небольшое количество упорядоченных кусочков. Если их становится много, то мы берем несколько кусочков и из них делаем один. Таким образом поддерживаем каждый раз небольшое количество.



Вот примерно, как это происходит. У нас по Х это номер вставки, т. е., грубо говоря, это время, которое вы вставляете. По У первичный ключ. Поступили данные. Они обозначены зеленым цветом. Они не упорядоченные.



Что делает ClickHouse? Прежде всего он их сортирует и потом записывает на диск. Появляется новый кусок. Что тут важно? Данные из вашего insert сразу попадут на диск, т. е. буферизации какой-то не будет и поэтому индивидуальными записями будет очень плохо писать.


Есть средства, например, буфер-таблицы в ClickHouse, которые позволяют снизить эту проблему, но все равно нужно делать вставки большими кусками. Мы рекомендуем делать это раз в секунду и, как минимум, 1 000 записей, иначе очень много будет езды по диску, и все будет очень плохо работать.



У нас появился новый кусок. Появилось что-то еще потом. Нужно количество кусков уменьшить. Вот этот процесс происходит в фоне. Называется слияние или merge.


У ClickHouse есть одна особенность. Слияние происходит только с кусками, которые были вставлены подряд. В нашем случае был кусок, которые объединяет вставки от M до N. И маленький кусочек, который мы вставили на предыдущем слайде, который был вставлен под номером N+1.



Мы берем их и сливаем. И получаем новый кусок М до N+1. Он упорядоченный.


Что тут важно? Этот процесс происходит в фоне. И за ним обязательно нужно следить, потому что, если там что-то пойдет не так, например, он замедлится или вы начнете слишком часто вставлять, и он не будет справляться, то рано или поздно все сломается.


Как это будет выглядеть в случае с ClickHouse? Когда у вас будет на партицию (партиция это месяц) 200 кусков, то у вас внезапно затормозятся вставки. Таким образом ClickHouse попробует дать возможность слияниям догнать вставки. А если уже будет 300 кусков, то он вам просто запретит вставлять, потому что иначе данные будет очень тяжело прочитать из множества кусков. Поэтому, если будете использовать ClickHouse, то обязательно это мониторьте. Настройте в ClickHouse экспорт метрик в Graphite. Это очень просто делается. И следите за количеством кусков в партиции. Если количество большое, то вам нужно обязательно разобраться с этим. Может быть, что-то с диском или вы начали очень сильно вставлять. И это нужно чинить.



Все хорошо. У нас есть сервер ClickHouse, все работает. Но иногда его может не хватать.


  • Самое банальное, когда данные на один сервер не поместились. Но это не единственный случай.
  • Например, хочется еще ускориться, добавив железа.
  • Или кластер ClickHouse стал таким популярным, что очень много одновременных запросов, они начинают мешать друг другу. И нужно добавить еще железа.

Что предлагает ClickHouse? Предлагает шардировать данные и использовать дистрибутивы таблицы.



Что это такое? Шардирование это понятно. У вас есть какая-то таблица. И вы ставите несколько компьютеров, и на каждом компьютере часть этих данных. У меня на картинке они в таблице local_table.


Что такое distributed таблицы? Это такой view над локальными таблицами, т. е. сама она данные не хранит. Она выступает как прокси, который отправит запрос на локальные таблицы. Ее можно создавать где угодно, хоть на отдельном компьютере от этих шардов, но стандартный способ это создание на каждом шарде. Вы тогда приходите в любой шард и создаете запрос.


Что она делает? Вот пошел запрос в select from distributed_table. Она возьмет его и перепишет distributed_table на local_table. И дальше отправит на все шарды сразу же.



Шарды запрос обработают. Причем они его стараются обрабатывать до самого конца практически, чтобы поменьше данных по сети передавать. Т. е. если у нас есть какая-то агрегация, то эта агрегация будет частично проведена на шардах. Они отошлют частично агрегированный результат на distributed таблицы. Distributed эту таблицу сольет и отправит полный результат пользователю.



Вот такой забавный benchmark. Чуть больше миллиарда строк. Это данные о поездках нью-йоркского такси. Они в открытом доступе лежат. Можно самому попробовать.


Посчитаем любой запрос. Тут средняя цена в зависимости от количества пассажиров. Типичный аналитический запрос. На одном компьютере чуть больше секунды. Если мы три компьютера доставим, то в три раза быстрее будет. Если у вас есть 140 компьютеров, то можно эти данные разложить по 140 компьютерам и запрос будет вообще быстро выполняться, т. е. за несколько миллисекунд. Конечно, это уже не 140-кратное ускорение, потому что сетевые задержки уже там начинают играть роль, но все равно. Запрос можно ускорять до самого конца.



Как теперь раскладывать данные по шардам?


Самый простой вариант, который мы рекомендуем, это взять и вручную разложить по локальным таблицам, потому что distributed таблица, когда она делает запрос, она не задумывается о том, как данные пошардированы, она просто спрашивает у всех шардов одновременно. Главное, чтобы данные не были задублированы, иначе у вас какая-то ерунда начнется в результатах. А так вы можете, как вам удобно раскладывать все по шардам.


Но, в принципе, distributed таблица и сама умеет это делать, хотя есть несколько нюансов.


Во-первых, записи асинхронные, т. е. если вы вставили в distributed эту таблицу, она данные отложит куда-то во временную папочку.



И дальше будет пытаться вставлять. Для этого ей нужен ключ шардирования. Она его посчитает, разделит на три. Там можно создавать веса. Далее выберет шард и вставит туда.


Что тут важно? Ключ шардирования можно даже рандом взять. Это тоже будет работать. Единственное, если вы хотите делать сложные joins и хотите, чтобы у вас данные, которые для joins нужны, были на одном шарде, тогда вам нужно уже задумываться о ключе шардирования.



Есть у нас кластер ClickHouse. Он большой, быстро работает, но иногда ломается. Иногда диски сбоят, а данные не хочется терять. И иногда там просто какие-то сетевые проблемы, а отчет нужно показывать. И данные о событиях, которые поступают постоянно, их тоже нельзя терять. Т. е. доступность должна быть и на чтение, и на запись.


ClickHouse предлагает для решения этой проблемы асинхронную мастер-мастер репликацию, которая работает на уровне таблиц ReplicatedMergeTree. На сервере у вас могут быть и реплицированные таблицы, и не реплицированные.



Это полный хаос. У нас есть несколько реплик. И тут изображены те же самые куски, о которых я говорил в предыдущей части презентации. Т. е. тут понимание остается такое же. Это частично сортированные кусочки данных. И реплики стараются вот этот набор кусков у себя синхронизировать друг между другом.


При этом может происходить три типа событий:


  • INSERT вставка в реплику
  • FETCH одна реплика скачала кусок с другой
  • MERGE реплика взяла несколько кусков и слила их в один

Как происходит вставка? Вставляем на любую реплику. Тут видно, что Реплика 1 не самая хорошая, но на нее все равно можно вставить. И информация о вставке записывается в ZooKeeper. Т. е. для того, чтобы у вас репликация работала, придется установить ZooKeeper.


При этом полный порядок на вставках все-таки поддерживается. У вас все реплики видят один и тот же набор кусков, и они видят в нем какие-то дырки, которых у них нет, и они пытаются их заполнить с помощью fetch.


Дальше нам нужно еще выполнять merge, т. е. сливать куски. Merge нужно выполнять согласовано, иначе наборы кусков разойдутся. Для этого одна реплика становится лидером. Не назовем мастером, потому что с мастером сразу идет ассоциация, что только туда можно вставлять, но это неправда. Т. е. у нас Реплика 2 лидер. Она решила, что эти куски надо помержить, записала это в ZooKeeper, остальные реплики об этом информацию получат и тоже сделают такой же merge.


При этом реплики все время сравнивают друг с другом checksums. Если что-то не так, они выбросят кусок и заново скачают, т. е. пытаются поддержать набор данных байт-идентичным. Это место тоже нужно обязательно мониторить, т. е. мониторить, как у вас идет репликация, какое отставание, чтобы не дай бог не сломалось.



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


Но консистентности данных в ClickHouse нет как и у любой системы с асинхронной репликацией. Вставляете данные в одну реплику, на второй реплике через пару секунд, может, появятся, а, может, через большее количество секунд. Есть зеленая звездочка хорошие новости: можно включить, т. е. указать настройку при вставке, при чтении и тогда чтение будет консистентным. Но, конечно, вы за это заплатите производительностью.


Доступность почти есть. Как сделать неубиваемый кластер ClickHouse? Берете три дата-центра. ZK в 3-х дата-центрах, а реплики, как минимум, в 2-х. И если у вас локация взрывается, то все продолжает работать и на чтение, и на запись.


Часто спрашивают: А как в двух дата-центрах сделать?. В двух не получится из-за ZooKeeper. Если у вас только две локации, то вы какой-то дата-центр объявляете главным. И если не главный отключается, то у вас все продолжает работать. Если главный отключаете, то у вас только на чтение.


Почему доступность почти? Почему красная звездочка? Строго говоря полной доступности нет, потому что нельзя записывать в сервер, если он у вас от quorum ZK, т. е. если это три ноды от двух нод отключены, тогда вы не сможете в него записать, но можете прочитать. Будут немножко отстающие данные.



Вот эти две фичи: distributed_table, replicated_table независимы. Их можно независимо использовать. Но они очень хорошо работают вместе. Нормальный кластер ClickHouse мы его примерно так себе представляем. Т. е. у нас есть N шардов и каждый трехкратно реплицирован. И distributed таблица умеет понимает, что шарды это реплики, могут отправлять запрос только в одну реплику шарда. И имеет отказоустойчивость, т. е. если у вас какая-то реплика недоступна, она в другую пойдет.


И еще один способ побороться с отсутствием consistency. Вы можете задать какое-то максимальное отставание. Если distributed таблица пришла на реплику, но отставание слишком большое, она другую попробует. Вот такой кластер будет хорошо работать.



Что такое ClickHouse?


  • Это столбцовая column-oriented база данных, которая позволяет очень быстро выполнять аналитические и интерактивные запросы.
  • Язык запросов это SQL с расширениями.
  • Плохо подходит для OLTP, потому что транзакций нет. Key-Value, потому что у нас разреженный индекс. Если вам нужна одна строчка, то вы много чего лишнего прочитаете. И если у вас Key-Value с большими blob, то это вообще будет плохо работать.
  • Линейно масштабируется, если шардировать и использовать distributed таблицы.
  • Отказоустойчивая, если использовать replicate таблицы.
  • И все это в open source с очень активным community.


Вопросы


Здравствуйте! Меня зовут Дмитрий. Спасибо за доклад! Есть вопрос про дублирование данных. Я так понимаю, что в ClickHouse нет никакого способа решать эту проблему. Ее надо решать на этапе вставки или все-таки есть какие-то методы, которыми мы можем побороться с дублированием данных у нас в базе?


Откуда может возникнуть дублирование данных? Если у вас вставляльщик, который вставляет данные, отказоустойчивый, то он там делает retry. И когда он начинает делать retry, то, например, бац и ClickHouse отключился, а он продолжает делать retry. И в этот момент, конечно, может возникнуть дублирование данных. Но в ClickHouse оно не возникнет. Как мы от этого защищаемся?



Вот эта вставка блоками. В ZK хранятся checksums последних ста блоков. Это тоже настраиваемо, но 100 неплохой вариант. Поэтому если вы вставили блок и у вас что-то взорвалось, и вы не уверены вставили вы или нет, то вставьте еще раз. Если вставка прошла, то ClickHouse это обнаружит и не будет дублировать данные.


Т. е. если мы вставляем по 10 000 строк, у нас там будет храниться миллион строк, которые будут гарантировано недублированными?


Нет. Дублирование работает не на уровне строк.


Т. е. кусок, который мы вставляем, он 10 000. Соответственно, мы можем рассчитывать, что последний миллион у нас не будет дублироваться, если мы захотим повторить.


Да, но только если выставляете прямо такими же блоками.


Т. е. идентичными блоками, да?


Да, для идентичного блока считается checksum. Если checksum совпадает, значит блок уже вставили и дублировать не надо.


Я понял. И второй вопрос. Меня интересуют запросы distributed таблицы к replicated таблицам. Я так понимаю, что запрос у нас идет только к одной реплике. Можно ли как-то настроить, чтобы какие-то тяжелые запросы шли на обе реплики, чтобы часть данных оттуда, часть данных оттуда каким-то образом доставалась?


Да, можно так настроить. Это еще один способ заиспользовать больше компьютеров. Есть специальная настройка, вы ее устанавливаете. По-моему, она называется max_parallel_replicas. Что она делает? Она половину данных обрабатывает на одной реплике, половину на другой. Но сразу оговорюсь, это работает только, если при создании таблицы был указан ключ сэмплирования. В ClickHouse есть такая фича ключ сэмплирования. Вы можете посчитать там запрос не по всем данным, а по одной десятой. И если у вас в реплицированной таблице задан ключ сэмплирования, то при указании этой настройки max_parallel_replicas, он сообразит, что можно одну вторую данных посчитать там и другую половину на второй реплике. И будет использовать обе.


Не будет ли еще раз сэмплирование?


Если вы не указали при запросе сэмпл, то сэмплирования не будет. Он просто это использует для того, чтобы разделить работу по двум репликам.


Понял, спасибо!


Спасибо за доклад! У меня три вопроса. Вы говорили, что индексы размазаны, т. е. там 8 000 с чем-то. И нужно писать большими объемами. Используете ли вы какую-то предбуферизацию? Рекомендуете ли вы что-то использовать?


Это больной вопрос, потому что все норовят по одной строчке вставлять. У нас в метрике, например, очень умный батчер, который отдельные команды разрабатывает, которые много дополнительной работы проводят, поэтому в ClickHouse его нет.


Что есть? Есть буфер-таблица, куда тоже по одной строчке не надо вставлять, потому что будет тормозить на какой-то ерунде. Но если вы туда вставляете, она по диску меньше ездит. Т. е. не будет на каждую вставку делать запись на диск. И очень многие люди, которые уже более серьезно подходят к ClickHouse, они используют Kafka. У вас в Kafka lock, ваша писалка берет записи из Kafka и вставляет их в ClickHouse. Это тоже хороший вариант.


Да, т. е. можно это вручную настроить. Еще вопрос. У нас есть distributed таблица, которая управляет всеми шардами. И, например, шард с distributed таблицы умер. Это значит, что все данные у нас умерли?


Distributed таблица не хранит ничего. Если она у вас умерла, то вы просто создаете заново, и все так же работает. Главное, сохранить local_tables, в которых данные лежат, поэтому, конечно, их нужно реплицировать.


И последний вопрос. Вы говорили, что можно вручную шардировать ее. А есть ли какие-то гарантированные транзакции, что я записал туда, записал туда, а если не записалось туда, то все это отменить? Я же пишу на три разных шарда.


Если кусок данных к вам пришел, то вы его должны на один шард записать. Только на один, главное, чтобы не было дублирование данных. В одно место записали и все хорошо.


Т. е. я должен хранить это, куда я записал?


Не обязательно это все хранить, потому что distributed таблица спрашивает все шарды. Когда у вас 500 серверов, как у нас, то начинает этого уже не хватать. Потому что на 500 серверов одновременно не очень хорошо ходить. Поэтому у нас там двухуровневое шардирование. Второй уровень уже знает, куда он положил данные и он на это обращает внимание. Но до 100 серверов этого за глаза хватает, т. е. просто во все серверы сходить и забрать результат обратно.


Спасибо большое!


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


Да, можно изменить целиком блок, точнее не блок, а всю партицию. Сейчас это месяц. Но у нас есть приоритетная задача сделать партицию произвольной. Вы берете и говорите alter table drop partition и он убирается, и вы можете обратно залить данные.


И еще есть кейсы, когда у вас какие-то данные, которые нужно обновить. Они есть и они немножко изменяются в реальном времени. Например, есть не хиты, а визиты пользователей. Т. е. пользователь пошел на одну страничку, на другую и у него длина визита растет. В ClickHouse для этого есть движок, который называется CollapsingMergeTree. Им не очень удобно пользоваться, но эту задачу он решает. И когда вы хотите что-то поменять, вы записываете две записи: первая это удалить предыдущую запись, вторая это записать новые данные. И эта таблица где-то у себя в фоне будет оптимизировать. И чуть-чуть вы можете изменять данные таким способом.


Вам нужно будет хранить всю эту строку где-то. Вы, конечно, можете сходить в ClickHouse и спросить: Какая последняя строка для этого ключа?, но это будет медленно уже.


И параллельный вопрос. Правильно ли я понимаю, что если мне нужна репликация на две точки, а не на три, то проще поставить Kafka и с нее две базы писать и не париться? Т. е. когда два дата-центра, а третьего нет.


Мы все-таки рекомендуем replicated таблицы. Почему? Потому что distributed таблица тоже умеет реплицировать.


Как replicated сделать на два ДЦ? У меня все равно получается, что если падает мастер, то писать никуда нельзя, а можно только читать. Или там какие-то простые способы? Slave сделать мастером, а потом догнать первого мастера до slave?


А с Kafka у вас как?


С Kafka я буду выливать каждую независимо. С Kafka все равно придется делать на три ДЦ.


Kafka тоже использует ZK.


На нее данных меньше надо. Но она только пару дней будет хранить, а не за всю историю, в Kafka меньше ресурсов. Но ее на три ДЦ дешевле делать, чем ClickHouse на три ДЦ.


ClickHouse не обязательно размазывать на три, вам только нужно ZK поставить.


А, все, только для quorum ZK, данные дублируются только два раза. Для quorum у нас есть ДЦ.


Почему мы еще рекомендуем replicated таблицу, а не просто в две таблицы запихивать? Потому что очень много проверок происходит, что данные именно идентичные. Если вы так будете записывать самостоятельно или через distributed таблицу, то легко получить какое-то расхождение. И потом оно уже никогда не исправится, потому что уже записали и до свидания. И не понятно разошлось или нет. А replicated таблицы непрерывно пытаются поддерживать вот эту общность и единый набор кусков.


У меня вопрос касается утилизации памяти. Как наиболее эффективно распределять ее? Сколько под instants выделять?


Про память такая история, что в ClickHouse есть такая настройка, как max_memory_usage. Это сколько вы можете отъесть, когда обрабатываете запрос. Для чего еще нужна память? Память нужна под дисковый кэш. Т. е. у ClickHouse нет какого-то хитрого кэша. Некоторые системы, как делают? Они читают o_direct с диска и как-то у себя кэшируют. ClickHouse так не делает. Какую-то часть памяти (довольно большую) нужно оставить под дисковый кэш. У вас данные, которые недавно читались с диска, будут потом из памяти прочитываться. И треть вы отводите на память, которая нужна в запросе.


Для чего ClickHouse расходует память? Если у вас запрос стриминговый, т. е. просто пройтись и что-то там посчитать, например, count, то будут единицы памяти расходоваться.


Где может понадобиться память? Когда у вас group by с большим количеством ключей. Например, вы по тем же самым referrers что-то группируете и у вас очень много различных referrers, urls. И все эти ключи нужно хранить. Если вам хватило памяти, то хорошо, а если не хватило, то есть возможность group by выполнить на диске, но это будет гораздо медленнее.


Это он сам определит?


Нужно включить.


Есть какой-то объем, который вы рекомендуете выстраивать? Например, 32 GB на ноду? Т. е., когда эффективно используется.


Чем больше, тем лучше. У нас 128 GB.


И один instance все 128 использует, да?


Да, конечно, он их все заиспользует. Конечно, если у вас данные не такие большие, то столько не нужно. Сколько данных, столько и памяти берите. Но если у вас данные уже в памяти не помещаются, то чем больше, тем лучше.


Алексей, спасибо за доклад! Вы не измеряли падение производительности при сильной фрагментации файловой системы?


Я сейчас конкретных цифр привести не могу. Конечно, такая проблема есть. Когда диски почти заполнены, то файлы уже не так расположены локально, начинается уже фрагментация. И просто надо уже следить за тем, что у вас не сильно заполнены диски. И если начинают заполняться, то нужно новую ноду ставить. Конкретные цифры привести не могу.


Хотя бы примерный порядок?


Не знаю, процентов до 70 нормально будет.


Спасибо!


Добрый день! У меня два вопроса. Насколько я знаю, сейчас у ClickHouse только http-интерфейс для обмена данными с клиентом. А есть ли какой-то roadmap, чтобы сделать бинарный интерфейс?


У нас есть два интерфейса. Это http, который можно любым http-клиентом использовать, который в JDBC-драйвере используется. И есть нативный интерфейс, который мы всегда считали приватным и не хотели для него какой-то библиотеки делать. Но интерфейс этот не такой сложный. И мы поддерживаем там прямую-обратную совместимость, поэтому добрые люди использовали исходники как документацию и в Go есть, я слышал, отличный драйвер, которые нативные клиенты используют. И для C++ мой коллега сделал отдельный драйвер, который позволяет использовать нативный интерфейс и вам не нужно линковаться со всем ClickHouse, чтобы его использовать. И для других языков тоже, наверное, есть. Точно не знаю. Т. е. формально мы его считаем нашим приватным, но по факту он уже публичный. Из некоторых языков им можно пользоваться.


Спасибо! Вы говорили, что написали свою систему репликации данных. Допустим, Impala использует HDFS для репликации данных, она не делает репликацию самостоятельно. Почему вы написали репликацию, чем она лучше, чем HDFS?


Интересный вопрос. ClickHouse развивается послойно. Сначала были простые merge таблицы, потом появилась для них репликация. И вот эта схема с кусками, которая мержится, она просто так на HDFS не ложится. Наверное, можно было использовать HDFS как файловую систему и там хранить куски, но проще на локальной файловой системе это делать.


Т. е. вы напрямую не сравнивали?


Нам нужно, чтобы вот эти слияния кусков тоже были неотъемлемой частью репликации, т. е. какое-то готовое решение для этого использовать, по-моему, нельзя.


Один кусок это будет один блок на HDFS и будет операция *opened*, если это возможно.


Т. е. использовать HDFS как хранилище?


Да. Чтобы не делать собственные репликации. Вы записали на HDFS и считайте, что оно уже реплицировано, и поддерживается отказоустойчивость.


А читать-то мы хотим с локального диска.


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


Интересная мысль, надо подумать.


Спасибо!

Подробнее..

Современная инфраструктура проблемы и перспективы

24.06.2020 14:16:49 | Автор: admin


В конце мая мы провели онлайн-митап на тему Современная инфраструктура и контейнеры: проблемы и перспективы. Поговорили о контейнерах, Kubernetes и оркестрации в принципе, о критериях выбора инфраструктуры и многом другом. Участники поделились кейсами из собственной практики.

Участники:

  • Евгений Потапов, CEO ITSumma. Больше половины его клиентов либо уже переходят, либо хотят перейти на Kubernetes.
  • Дмитрий Столяров, CTO Флант. Обладает 10+ годами опыта работы с контейнерными системами.
  • Денис Ремчуков (aka Eric Oldmann), COO argotech.io, ex-РАО ЕЭС. Пообещал рассказать о кейсах в кровавом энтерпрайзе.
  • Андрей Федоровский, CTO News360.comПосле покупки компании другим игроком отвечает за ряд ML и AI проектов и за инфраструктуру.
  • Иван Круглов, системный инженер, exBooking.com.Тот самый человек, который своими руками сделал с Kubernetes очень многое.


Темы:

  • Инсайты участников про контейнеры и оркестрацию (Docker, Kubernetes и прочее); что пробовали на практике или анализировали.
  • Кейс: В компании строят план развития инфраструктуры на годы. Как принимается решение, строить (или переводить текущую) инфраструктуру на контейнерах и Кубер или нет?
  • Проблемы в мире cloud-native, чего не хватает, давайте пофантазируем, что будет завтра.


Завязалась интересная дискуссия, мнения участников оказались настолько разными и вызвали столько комментариев, что хочется поделиться ими с вами. Есть видео на три часа, а ниже выжимка из дискуссии.



Kubernetes это уже стандарт или отличный маркетинг?


Мы к нему (Kubernetes. Ред.) пришли тогда, когда о нем еще никто не знал. Мы к нему пришли еще тогда, когда его не было. Мы его хотели до этого Дмитрий Столяров


Фото с Reddit.com

Лет 5-10 назад существовало огромное количество инструментов, и не было единого стандарта. Каждые полгода появлялся новый продукт, а то и не один. Сначала Vagrant, затем Salt, Chef, Puppet, и ты каждые полгода перестраиваешь свою инфраструктуру. У тебя пять админов, которые постоянно заняты тем, что переписывают конфиги вспоминает Андрей Федоровский. Он считает, что Docker и Kubernetes задавили остальных. Docker стал стандартом в течение последних пяти лет, Kubernetes в последние два года. И это хорошо для индустрии.

Дмитрий Столяров и его команда любят Кубер. Они хотели такой инструмент до того как он появился, и пришли к нему когда о нем еще никто не знал. На текущий момент, из соображений удобства, они не берут клиента, если понимают, что не внедрят у него Kubernetes. При этом, по словам Дмитрия, у компании множество гигантских success story по переделке жуткого legacy.

Kubernetes это не только контейнерная оркестрация, это система управления конфигурацией с развитым API, компонентом работы с сетью, L3 балансировкой и Ingress контроллерами, которая позволяет относительно легко управлять ресурсами, масштабироваться и абстрагироваться от нижних слоев инфраструктуры.

К сожалению, в нашей жизни за всё нужно платить. И этот налог большой, особенно если говорить о переходе на Kubernetes компании с развитой инфраструктурой, как считает Иван Круглов. Он свободно мог бы работать как в компании с традиционной инфраструктурой, так и с Кубером. Главное, понимать особенности компании и рынка. Но, например, для Евгения Потапова, который обобщил бы Kubernetes до любого инструмента оркестрации контейнеров, такой вопрос не стоит.

Евгений провел аналогию с ситуацией в 1990-х годах, когда появилось объектно-ориентированное программирование, как способ программирования сложных приложений. На тот момент не прекращались дебаты и появлялись новые инструменты, поддерживающие ООП. Затем появились микросервисы как способ уйти от монолитной концепции. Это, в свою очередь, привело к появлению контейнеров и инструментов их управления. Я думаю, что скоро мы придем к тому времени, когда не будет стоять вопрос о том, стоит ли писать микросервисно маленькое приложение, его будут писать по дефолту микросервисом, считает он. Аналогично, Docker и Kubernetes со временем станут стандартным решением без необходимости выбора.

Проблема баз в stateless



Photo by Twitter: @jankolario on Unsplash

В наше время найдется много рецептов запуска баз данных в Kubernetes. Даже как отделять часть, работающую с диском I/O от, условно, application части базы. Возможно ли, что в будущем базы данных видоизменятся настолько, что будут поставляться в коробке, где одна часть будет оркестрироваться через Docker и Kubernetes, а в другой части инфраструктуры, через отдельный софт, будет предоставляться storage часть? Базы видоизменятся как продукт?

Это описание похоже на менеджмент очередей, но требования к надежности и синхронности информации в традиционных базах данных предъявляются гораздо выше, считает Андрей. Cache hit ratio в нормальных базах держится на уровне 99 %. Если worker ложится, запускается новый, и кэш разогревается с нуля. Пока кэш не разогрет, worker работает медленно, значит на него нельзя пускать пользовательскую нагрузку. Пока нет пользовательской нагрузки, кэш не разогревается. Это замкнутый круг.

Дмитрий в корне не согласен, кворумы и шардирование решают проблему. Но Андрей настаивает, что решение подходит не всем. В некоторых ситуациях подойдет кворум, но он дает дополнительную нагрузку на сеть. База NoSQL подходит не во всех случаях.

Участники митапа разделились на два лагеря.

Денис и Андрей утверждают, что все, что пишет на диск базы и прочее, в текущей экосистеме Кубера сделать невозможно. Невозможно сохранить целостность и консистентность продуктивных данных в Kubernetes. Это фундаментальная особенность. Решение: гибридная инфраструктура.

Даже современные cloud native базы, такие как MongoDB и Cassandra, или очереди сообщений, как Kafka или RabbitMQ, требуют постоянные хранилища данных вне Kubernetes.

Евгений возражает: Базы в Кубере травма околороссийская, или околоэнтерпрайзная, которая связана с тем, что в России Cloud Adoption нет. Малые или средние компании на Западе это Cloud. Базы Amazon RDS использовать проще, чем самим возиться с Kubernetes. В России используют Кубер on-premise и переносят в него базы, когда пытаются избавиться от зоопарка.

Дмитрий тоже не согласился с утверждением, что никакие базы нельзя держать в Kubernetes: База базе рознь. И если пихать гигантскую реляционную базу то ни в коем случае. Если пихать что-то небольшое и cloud native, что морально готово к полуэфемерной жизни, все будет хорошо. Дмитрий упомянул также, что инструменты управления базами не готовы ни к Docker, ни к Kuber, поэтому возникают большие сложности.

Иван, в свою очередь, уверен, что даже если абстрагироваться от понятий stateful и stateless, экосистема энтерпрайзных решений в Kubernetes еще не готова. С Кубером сложно поддерживать требования законодательных и регулирующих органов. Например, невозможно сделать решение предоставления identity, где требуются строгие гарантии идентификации сервера, вплоть до железа, которое вставляется в серверы. Эта сфера развивается, но пока решения нет.
Участникам не удалось договориться, поэтому в этой части выводов не последует. Лучше приведем пару практических примеров.

Кейс 1. Кибербезопасность мегарегулятора с базами вне Кубера


В случае развитой системы кибербезопасности, использование контейнеров и оркестрации позволяет отбиваться от атак и вторжений. Например, в одном мегарегуляторе Денис и его команда реализовали связку оркестратора с обученным SIEM-сервисом, который анализирует логи в реальном времени и определяет процесс атаки, взлома или сбоя. В случае атаки, попытки что-то положить, или при вторжении вируса-вымогателя, он через оркестратор поднимает контейнеры с applicationами быстрее, чем они заражаются, или быстрее, чем их атакует злоумышленник.

Кейс 2. Частичный переезд баз данных Booking.com в Kubernetes


В Booking.com основная база данных это MySQL с асинхронной репликацией есть master и целая иерархия слейвов. К моменту ухода Ивана из компании был запущен проект по переносу cлейвов, которые можно отстрелить с определенным ущербом.

Помимо основной базы есть инсталляция Cassandra с самописной оркестрацией, которую писали еще до того как Кубер вышел в мейнстрим. Проблем в этом плане нет, но у нее persistent на локальные SSD. Удаленный хранилища, даже в рамках одного дата-центра, не используются из-за проблемы с высокой задержкой.

Третий класс баз данных это поисковый сервис Booking.com, где каждая нода сервиса является базой данных. Попытки перенести поисковый сервис в Кубер не удались, потому что каждая нода это 6080 Гб локального storage, которые сложно поднять и разогреть.

В итоге поисковый движок в Kubernetes не перенесли, и Иван не думает, что будут новые попытки в ближайшее время. База MySQL была перенесена наполовину: только Слейвы, которые не страшно отстрелить. Cassandra прижилась отлично.

Выбор инфраструктуры как задача без общего решения



Photo by Manuel Geissinger from Pexels

Предположим, у нас есть новая компания, или компания, где часть инфраструктуры построена по-старому. В ней строят план развития инфраструктуры на годы. Как принимается решение, строить инфраструктуру на контейнерах и Кубер или нет?

Компании, которые бьются за наносекунды исключены из дискуссии. Здоровый консерватизм окупается из соображений надежности, но тем не менее, есть компании, которым стоит рассмотреть новые подходы.

Иван: Я бы сейчас, безусловно, стартовал компанию в сloud, просто потому, что это быстрее, хотя не обязательно дешевле. С развитием венчурного капитализма у стартапов с деньгами больших проблем нет, и основная задача завоевать рынок.

Иван придерживается взгляда, что развитость текущей инфраструктуры является критерием выбора. Если в прошлом были серьезные вложения, и это работает, то нет смысла переделывать. Если же инфраструктура не развита, и есть проблемы с инструментами, безопасностью и мониторингом, то есть смысл посмотреть на распределенную инфраструктуру.

Налог заплатить придется в любом случае, и Иван платил бы тот, который позволил ему в будущем платить меньше. Потому что просто за счет того, что я еду в поезде, который двигают другие, я проеду намного дальше, чем если я сяду в другой поезд, в который мне придется топливо закидывать самому. говорит Иван. Когда компания новая, и требования к latency десятки миллисекунд, то Иван смотрел бы в сторону операторов, в которые сегодня заворачивают классические базы данных. Они поднимают replication chain, который сам переключается в случае failover и т.п

Для маленькой компании с парой серверов в Кубере смысла нет, утверждает Андрей. Но если она планирует вырасти до сотни серверов и больше, то нужна автоматизация и система управления ресурсами. 90 % случаев оправдывают затраты. Причем вне зависимости от уровня нагрузки и ресурсов. Всем, начиная от стартапов, заканчивая крупными компаниями с миллионной аудиторией, имеет смысл постепенно смотреть в сторону продуктов для оркестрации контейнеров. Да, это действительно будущее, уверен Андрей.

Денис обозначил два главных критерия масштабируемость и устойчивость работы. Он выберет те инструменты, которые лучше всего подходят для этой задачи. Это может быть на коленке собранный ноунейм, и на нем Nutanix Community Edition. Это может быть вторая линия в виде application на Kuber с базой данных на бэкенде, которая реплицируется и имеет заданные параметры RTO и RPO (recovery time/point objectives прим).

Евгений обозначил возможную проблему с кадрами. На текущий момент на рынке не так много высококлассных специалистов, разбирающихся в кишках. Действительно, если выбранная технология стара, то сложно нанять кого-то, кроме немолодых скучающих и уставших от жизни людей. Хотя другие участники считают, что это вопрос подготовки кадров.
Если поставить вопрос выбора: запускать небольшую компанию в Public Cloud с базами в Amazon RDS или on premise с базами в Kubernetes, то несмотря на некоторые недостатки, выбором участников стал Amazon RDS.

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

Оценка использования Kubernetes


Слушатель Антон Жбанков задал вопрос-западню апологетам Kubernetes: как выбирали и проводили технико-экономическое обоснование? Почему Kubernetes, почему не виртуальные машины, например?


Photo by Tatyana Eremina on Unsplash

На него ответили Дмитрий и Иван. В обоих случаях методом проб и ошибок, была сделана последовательность решений, в результате которой оба участника пришли к Kubernetes. Сейчас бизнес начинает самостоятельно разрабатывать софт, который имеет смысл переносить в Кубер. Речь не идет о классических сторонних системах, типа 1С. Kubernetes помогает, когда разработчикам нужно оперативно делать релизы, при безостановочном Continuous Improvement.

Команда Андрея пробовала делать масштабируемый кластер на основе виртуальных машин. Ноды падали как домино, что иногда приводило к падению кластера. Теоретически можно доделать и поддерживать руками, но муторно. И если на рынке есть решение, которое позволяет работать из коробки, то мы с удовольствием идем к нему. И мы перешли в результате., рассказывает Андрей.

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

Что нас ждёт



Photo by Drew Beamer on Unsplash

Когда технологии развиваются, появляется все больше разрозненных кусочков, а затем происходит фазовый переход, появляется вендор, который убил достаточно бабла, чтобы все соединилось вместе в едином инструменте.

Не кажется ли вам, что наступит момент, когда появится инструмент, каким стала Ubuntu для мира Linux? Возможно, единый инструмент контейнеризации и оркестрации включит в себя и Кубер. С ним станет просто строить on-premise облака.

Ответ дал Иван: Google сейчас строит Anthos это их пакетное предложение, которое разворачивает облако и включает в себя Kuber, Service Mesh, мониторинг, всю обвязку, которая нужна для микросервисов в on-premise. Мы почти в будущем.

Денис также упомянул Nutanix и VMWare с продуктом vRealize Suite, которые могут справляются с подобной задачей без контейнеризации.

Дмитрий поделился мнением, что уменьшение боли и снижение налога это два направления, где стоит ожидать улучшений.

Подводя итог дискуссии, выделим следующие проблемы современной инфраструктуры
  • Сразу трое участников обозначили проблему со stateful.
  • Различного рода проблемы поддержки безопасности, включая вероятность того, что итоге в Docker окажутся несколько версий Python, application-серверов и компонент.
    Перерасход, о котором лучше сделать отдельный митап.
    Проблема обучения, так как оркестрация представляет собой сложную экосистему.
    Общая проблема отрасли использование инструментов не по назначению.


    Остальные выводы делать вам. Пока остается ощущение, что связке Docker+Kubernetes непросто стать центральной частью системы. Например, операционные системы ставятся на железку первыми, чего нельзя сказать о контейнерах и оркестрации. Возможно, в будущем срастутся операционки и контейнеры с cloud management софтом.


    Photo by Gabriel Santos Fotografia from Pexels

    Пользуясь случаем напомню, что у нас есть фейсбук-группа Управление и разработка крупных IT-проектов, канал @feedmeto с интересными публикациями из разных техно-блогов. И мой канал @rybakalexey, где я рассказываю об управлении разработкой в продуктовых компаниях.
Подробнее..

Восходящая сортировка кучей

03.07.2020 00:15:20 | Автор: admin

Это заключительная статья из серии про сортировки кучей. В предыдущих лекциях мы рассмотрели весьма разнообразные кучные структуры, показывающих отличные результаты по скорости. Напрашивается вопрос: а какая куча наиболее эффективна, если речь идёт о сортировке? Ответ таков: та, которую мы рассмотрим сегодня.
EDISON Software - web-development
Мы в EDISON в наших проектах используем только лучшие методологии разработки.


Когда мы дорабатывали приложения и сайты Московского ювелирного завода мы сделали полный аудит имеющихся веб-ресурсов, переписали их на Python и Django, внедрили SDK для обращения к видеосервису и рассылки SMS-оповещений, произвели интеграцию с системой электронного документооборота и API 2ГИС.

Мы работаем с ювелирной точностью ;-)
Необычные кучи, которые мы рассматривали ранее это, конечно, прекрасно, однако самая эффективная куча стандартная, но с улучшенной просейкой.

Что такое просейка, зачем она нужна в куче и как она работает описано в самой первой части серии статей.

Стандартная просейка в классической сортировке кучей работает грубо в лоб элемент из корня поддерева отправляется в буфер обмена, элементы из ветки по результатам сравнения поднимаются наверх. Всё достаточно просто, но получается слишком много сравнений.



Сравнения экономятся за счёт того, что родители почти не сравниваются с потомками, в основном, только потомки сравниваются друг с другом. В обычной heapsort и родитель сравнивается с потомками и потомки сравниваются друг с другом поэтому сравнений получается почти в полтора раза больше при том же количестве обменов.

Итак, как это работает, давайте посмотрим на конкретном примере.

Итак, допустим у нас массив, в котором уже почти сформирована куча осталось только просеять корень. Для всех остальных узлов выполнено условие любой потомок не больше своего родителя.

Прежде всего, от того узла, для которого совершается просейка нужно спуститься вниз, по большим потомкам. Куча бинарная то есть у нас левый потомок и правый потомок. Спускаемся в ту ветку, где потомок крупнее. На этом этапе и происходит основное количество сравнений левый/правый потомки сравниваются друг с другом.



Достигнув листа на последнем уровне, мы тем самым определились с той веткой, значения в которой нужно сдвинуть вверх. Но сдвинуть нужно не всю ветку, а только ту часть, которая крупнее чем корень с которого начали.

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



Ну и последний шаг используя буферную переменную сдвигаем значения узлов вверх по ветке.



Теперь всё. Восходящая просейка сформировала из массива сортирующее дерево, в котором любой родитель больше чем его потомки.

Итоговая анимация:



Реализация на Python 3.7


Основной алгоритм сортировки ничем не отличается от обычной heapsort:

# Основной алгоритм сортировки кучейdef HeapSortBottomUp(data):    # Формируем первоначальное сортирующее дерево    # Для этого справа-налево перебираем элементы массива    # (у которых есть потомки) и делаем для каждого из них просейку    for start in range((len(data) - 2) // 2, -1, -1):        HeapSortBottomUp_Sift(data, start, len(data) - 1)     # Первый элемент массива всегда соответствует корню сортирующего дерева    # и поэтому является максимумом для неотсортированной части массива.    for end in range(len(data) - 1, 0, -1):         # Меняем этот максимум местами с последним         # элементом неотсортированной части массива        data[end], data[0] = data[0], data[end]        # После обмена в корне сортирующего дерева немаксимальный элемент        # Восстанавливаем сортирующее дерево        # Просейка для неотсортированной части массива        HeapSortBottomUp_Sift(data, 0, end - 1)    return data

Спуск до нижнего листа удобно/наглядно вынести в отдельную функцию:

# Спуск вниз до самого нижнего листа# Выбираем бОльших потомковdef HeapSortBottomUp_LeafSearch(data, start, end):        current = start        # Спускаемся вниз, определяя какой    # потомок (левый или правый) больше    while True:        child = current * 2 + 1 # Левый потомок        # Прерываем цикл, если правый вне массива        if child + 1 > end:             break         # Идём туда, где потомок больше        if data[child + 1] > data[child]:            current = child + 1        else:            current = child        # Возможна ситуация, если левый потомок единственный    child = current * 2 + 1 # Левый потомок    if child <= end:        current = child            return current

И самое главное просейка, сначала идущая вниз, затем выныривающая наверх:

# Восходящая просейкаdef HeapSortBottomUp_Sift(data, start, end):        # По бОльшим потомкам спускаемся до самого нижнего уровня    current = HeapSortBottomUp_LeafSearch(data, start, end)        # Поднимаемся вверх, пока не встретим узел    # больший или равный корню поддерева    while data[start] > data[current]:        current = (current - 1) // 2        # Найденный узел запоминаем,    # в этот узел кладём корень поддерева    temp = data[current]    data[current] = data[start]        # всё что выше по ветке вплоть до корня    # - сдвигаем на один уровень вниз    while current > start:        current = (current - 1) // 2        temp, data[current] = data[current], temp  

На просторах Интернета также обнаружен код на C
/*----------------------------------------------------------------------*//*                         BOTTOM-UP HEAPSORT                           *//* Written by J. Teuhola <teuhola@cs.utu.fi>; the original idea is      *//* probably due to R.W. Floyd. Thereafter it has been used by many      *//* authors, among others S. Carlsson and I. Wegener. Building the heap  *//* bottom-up is also due to R. W. Floyd: Treesort 3 (Algorithm 245),    *//* Communications of the ACM 7, p. 701, 1964.                           *//*----------------------------------------------------------------------*/#define element float/*-----------------------------------------------------------------------*//* The sift-up procedure sinks a hole from v[i] to leaf and then sifts   *//* the original v[i] element from the leaf level up. This is the main    *//* idea of bottom-up heapsort.                                           *//*-----------------------------------------------------------------------*/static void siftup(v, i, n) element v[]; int i, n; {  int j, start;  element x;  start = i;  x = v[i];  j = i << 1;  /* Leaf Search */  while(j <= n) {    if(j < n) if v[j] < v[j + 1]) j++;    v[i] = v[j];    i = j;    j = i << 1;  }  /* Siftup */  j = i >> 1;  while(j >= start) {    if(v[j] < x) {      v[i] = v[j];      i = j;      j = i >> 1;    } else break;  }  v[i] = x;} /* End of siftup *//*----------------------------------------------------------------------*//* The heapsort procedure; the original array is r[0..n-1], but here    *//* it is shifted to vector v[1..n], for convenience.                    *//*----------------------------------------------------------------------*/void bottom_up_heapsort(r, n) element r[]; int n; {  int k;   element x;  element *v;  v = r - 1; /* The address shift */  /* Build the heap bottom-up, using siftup. */  for (k = n >> 1; k > 1; k--) siftup(v, k, n);  /* The main loop of sorting follows. The root is swapped with the last  */  /* leaf after each sift-up. */  for(k = n; k > 1; k--) {    siftup(v, 1, k);    x = v[k];    v[k] = v[1];    v[1] = x;  }} /* End of bottom_up_heapsort */

Чисто эмпирически по моим замерам восходящая сортировка кучей работает в 1,5 раза быстрее, чем обычная сортировка кучей.

По некоторой информации (на странице алгоритма в Википедии, в приведённых PDF в разделе Ссылки) BottomUp HeapSort в среднем опережает даже быструю сортировку для достаточно крупных массивов размером от 16 тысяч элементов.

Ссылки


Bottom-up heapsort

A Variant of Heapsort with Almost Optimal Number of Comparisons

Building Heaps Fast

A new variant of heapsort beating, on an average, quicksort(if n is not very small)

Статьи серии:



В приложение AlgoLab добавлена сегодняшняя сортировка, кто пользуется обновите excel-файл с макросами.
Подробнее..

Перевод О нет! Моя Data Science ржавеет

04.07.2020 14:10:42 | Автор: admin
Привет, Хабр!

Предлагаем вашему вниманию перевод интереснейшего исследования от компании Crowdstrike. Материал посвящен использованию языка Rust в области Data Science (применительно к malware analysis) и демонстрирует, в чем Rust на таком поле может посоперничать даже с NumPy и SciPy, не говоря уж о чистом Python.


Приятного чтения!

Python один из самых популярных языков программирования для работы с data science, и неслучайно. В индексе пакетов Python (PyPI) найдется огромное множество впечатляющих библиотек для работы с data science, в частности, NumPy, SciPy, Natural Language Toolkit, Pandas и Matplotlib. Благодаря изобилию высококачественных аналитических библиотек в доступе и обширному сообществу разработчиков, Python очевидный выбор для многих исследователей данных.

Многие из этих библиотек реализованы на C и C++ из соображений производительности, но предоставляют интерфейсы внешних функций (FFI) или привязки Python, так, чтобы из функции можно было вызывать из Python. Эти реализации на более низкоуровневых языках призваны смягчить наиболее заметные недостатки Python, связанные, в частности, с длительностью выполнения и потреблением памяти. Если удается ограничить время выполнения и потребление памяти, то сильно упрощается масштабируемость, что критически важно для сокращения расходов. Если мы сможем писать высокопроизводительный код, решающий задачи data science, то интеграция такого кода с Python станет серьезным преимуществом.

При работе на стыке data science и анализа вредоносного ПО требуется не только скоростное выполнение, но и эффективное использование разделяемых ресурсов, опять же, для масштабирования. Проблема масштабирования является одной из ключевых в области больших данных, как, например, эффективная обработка миллионов исполняемых файлов на множестве платформ. Для достижения хорошей производительности на современных процессорах требуется параллелизм, обычно реализуемый при помощи многопоточности; но также необходимо повышать эффективность выполнения кода и расхода памяти. При решении подобных задач бывает сложно сбалансировать ресурсы локальной системы, а правильно реализовать многопоточные системы даже сложнее. Суть C и C++ такова, что потокобезопасность в них не предоставляется. Да, существуют внешние платформо-специфичные библиотеки, но обеспечение потокобезопасности, это, очевидно, долг разработчика.

Парсинг вредоносного ПО это в принципе опасное занятие. Вредоносное ПО часто непредусмотренным образом обращается со структурами данных, касающимися формата файлов, таким образом выводя из строя аналитические утилиты. Относительно распространенная западня, поджидающая нас в Python, связана с отсутствием хорошей типобезопасности. Python, великодушно принимающий значения None, когда на месте них ожидался bytearray, может скатиться в полный бедлам, избежать которого можно, лишь нашпиговав код проверками на None. Такие допущения, связанные с утиной типизацией часто приводят к обвалам.

Но есть Rust. Язык Rust во многом позиционируется как идеальное решение всех потенциальных проблем, обрисованных выше: время выполнения и потребление памяти сравнимы с C и C++, а также предоставляется обширная типобезопасность. Также в языке Rust предоставляются дополнительные приятности, в частности, серьезные гарантии безопасности памяти и никаких издержек времени исполнения. Поскольку таких издержек нет, упрощается интеграция кода Rust с кодом других языков, в частности, Python. В этой статье мы сделаем небольшую экскурсию по Rust, чтобы понять, достоин ли он связанного с ним хайпа.

Пример приложения для Data Science


Data science очень широкая предметная область со множеством прикладных аспектов, и обсудить их все в одной статье невозможно. Простая задача для data science вычисление информационной энтропии для байтовых последовательностей. Общая формула для расчета энтропии в битах приводится в Википедии:



Чтобы рассчитать энтропию для случайной переменной X, мы сначала считаем, сколько раз встречается каждое возможное байтовое значение , а потом делим это число на общее количество встречающихся элементов, чтобы вычислить вероятность встретить конкретное значение , соответственно . Затем считаем отрицательное значение от взвешенной суммы вероятностей конкретного значения xi, встречающегося , а также так называемую собственную информацию . Поскольку мы вычисляем энтропию в битах, здесь используется (обратите внимание на основание 2 для бит).

Давайте испробуем Rust и посмотрим, как он справляется с вычислением энтропии по сравнению с чистым Python, а также с некоторыми популярнейшими библиотеками Python, упомянутыми выше. Это упрощенная оценка потенциальной производительности Rust в области data science; данный эксперимент не является критикой Python или отличных библиотек, имеющихся в нем. В этих примерах мы сгенерируем собственную библиотеку C из кода Rust, который сможем импортировать из Python. Все тесты проводились на Ubuntu 18.04.

Чистый Python


Начнем с простой функции на чистом Python (в entropy.py) для расчета энтропии bytearray, воспользуемся при этом только математическим модулем из стандартной библиотеки. Эта функция не оптимизирована, возьмем ее в качестве отправной точки для модификаций и измерения производительности.

import mathdef compute_entropy_pure_python(data):    """Compute entropy on bytearray `data`."""    counts = [0] * 256    entropy = 0.0    length = len(data)    for byte in data:        counts[byte] += 1    for count in counts:        if count != 0:            probability = float(count) / length            entropy -= probability * math.log(probability, 2)    return entropy

Python с NumPy и SciPy


Неудивительно, что в SciPy предоставляется функция для расчета энтропии. Но сначала мы воспользуемся функцией unique() из NumPy для расчета частот байтов. Сравнивать производительность энтропийной функции SciPy с другими реализациями немного нечестно, так как в реализации из SciPy есть дополнительный функционал для расчета относительной энтропии (расстояния Кульбака-Лейблера). Опять же, мы собираемся провести (надеюсь, не слишком медленный) тест-драйв, чтобы посмотреть, какова будет производительность скомпилированных библиотек Rust, импортированных из Python. Будем придерживаться реализации из SciPy, включенной в наш скрипт entropy.py.

import numpy as npfrom scipy.stats import entropy as scipy_entropydef compute_entropy_scipy_numpy(data):    """Вычисляем энтропию bytearray `data` с SciPy и NumPy."""    counts = np.bincount(bytearray(data), minlength=256)    return scipy_entropy(counts, base=2)

Python с Rust


Далее мы несколько подробнее исследуем нашу реализацию на Rust, по сравнению с предыдущими реализациями, ради основательности и закрепления. Начнем с принимаемого по умолчанию библиотечного пакета, сгенерированного при помощи Cargo. В следующих разделах показано, как мы модифицировали пакет Rust.

cargo new --lib rust_entropyCargo.toml

Начинаем с обязательного файла манифеста Cargo.toml, в котором определяем пакет Cargo и указываем имя библиотеки, rust_entropy_lib. Используем общедоступный контейнер cpython (v0.4.1), доступный на сайте crates.io, в реестре пакетов Rust Package Registry. В статье мы используем Rust v1.42.0, новейшую стабильную версию, доступную на момент написания.

[package] name = "rust-entropy"version = "0.1.0"authors = ["Nobody <nobody@nowhere.com>"] edition = "2018"[lib] name = "rust_entropy_lib"crate-type = ["dylib"][dependencies.cpython] version = "0.4.1"features = ["extension-module"]

lib.rs


Реализация библиотеки Rust весьма проста. Как и в случае с нашей реализацией на чистом Python, мы инициализируем массив counts для каждого возможного значения байт и перебираем данные для наполнения counts. Для завершения операции вычисляем и возвращаем отрицательную сумму вероятностей, умноженную на вероятностей.

use cpython::{py_fn, py_module_initializer, PyResult, Python};/// вычисляем энтропию массива байтfn compute_entropy_pure_rust(data: &[u8]) -> f64 {    let mut counts = [0; 256];    let mut entropy = 0_f64;    let length = data.len() as f64;    // collect byte counts    for &byte in data.iter() {        counts[usize::from(byte)] += 1;    }    // вычисление энтропии    for &count in counts.iter() {        if count != 0 {            let probability = f64::from(count) / length;            entropy -= probability * probability.log2();        }    }    entropy}

Все, что нам остается взять из lib.rs это механизм для вызова чистой функции Rust из Python. Мы включаем в lib.rs функцию, приспособленную к работе с CPython (compute_entropy_cpython()) для вызова нашей чистой функции Rust (compute_entropy_pure_rust()). Поступая таким образом, мы только выигрываем, так как будем поддерживать единственную чистую реализацию Rust, а также предоставим обертку, удобную для работы с CPython.

/// Функция Rust для работы с CPython fn compute_entropy_cpython(_: Python, data: &[u8]) -> PyResult<f64> {    let _gil = Python::acquire_gil();    let entropy = compute_entropy_pure_rust(data);    Ok(entropy)}// инициализируем модуль Python и добавляем функцию Rust для работы с CPython py_module_initializer!(    librust_entropy_lib,    initlibrust_entropy_lib,    PyInit_rust_entropy_lib,    |py, m | {        m.add(py, "__doc__", "Entropy module implemented in Rust")?;        m.add(            py,            "compute_entropy_cpython",            py_fn!(py, compute_entropy_cpython(data: &[u8])            )        )?;        Ok(())    });

Вызов кода Rust из Python


Наконец, вызываем реализацию Rust из Python (опять же, из entropy.py). Для этого сначала импортируем нашу собственную динамическую системную библиотеку, скомпилированную из Rust. Затем просто вызываем предоставленную библиотечную функцию, которую ранее указали при инициализации модуля Python с использованием макроса py_module_initializer! в нашем коде Rust. На данном этапе у нас всего один модуль Python (entropy.py), включающий функции для вызова всех реализаций расчета энтропии.

import rust_entropy_libdef compute_entropy_rust_from_python(data):    ""Вычисляем энтропию bytearray `data` при помощи Rust."""    return rust_entropy_lib.compute_entropy_cpython(data)

Мы собираем вышеприведенный библиотечный пакет Rust на Ubuntu 18.04 при помощи Cargo. (Эта ссылка может пригодиться пользователям OS X).

cargo build --release

Закончив со сборкой, мы переименовываем полученную библиотеку и копируем ее в тот каталог, где находятся наши модули Python, так, чтобы ее можно было импортировать из сценариев. Созданная при помощи Cargo библиотека называется librust_entropy_lib.so, но ее потребуется переименовать в rust_entropy_lib.so, чтобы успешно импортировать в рамках этих тестов.

Проверка производительности: результаты


Мы измеряли производительность каждой реализации функции при помощи контрольных точек pytest, рассчитав энтропию более чем для 1 миллиона случайно выбранных байт. Все реализации показаны на одних и тех же данных. Эталонные тесты (также включенные в entropy.py) показаны ниже.

# ### КОНТРОЛЬНЕ ТОЧКИ #### генерируем случайные байты для тестирования w/ NumPyNUM = 1000000VAL = np.random.randint(0, 256, size=(NUM, ), dtype=np.uint8)def test_pure_python(benchmark):    """тестируем чистый Python."""    benchmark(compute_entropy_pure_python, VAL)def test_python_scipy_numpy(benchmark):    """тестируем чистый Python со SciPy."""    benchmark(compute_entropy_scipy_numpy, VAL)def test_rust(benchmark):    """тестируем реализацию Rust, вызываемую из Python."""    benchmark(compute_entropy_rust_from_python, VAL)

Наконец, делаем отдельные простые драйверные скрипты для каждого метода, нужного для расчета энтропии. Далее идет репрезентативный драйверный скрипт для тестирования реализации на чистом Python. В файле testdata.bin 1 000 000 случайных байт, используемых для тестирования всех методов. Каждый из методов повторяет вычисления по 100 раз, чтобы упростить захват данных об использовании памяти.

import entropywith open('testdata.bin', 'rb') as f:    DATA = f.read()for _ in range(100):    entropy.compute_entropy_pure_python(DATA)

Реализации как для SciPy/NumPy, так и для Rust показали хорошую производительность, легко обставив неоптимизированную реализацию на чистом Python более чем в 100 раз. Версия на Rust показала себя лишь немного лучше, чем версия на SciPy/NumPy, но результаты подтвердили наши ожидания: чистый Python гораздо медленнее скомпилированных языков, а расширения, написанные на Rust, могут весьма успешно конкурировать с аналогами на C (побеждая их даже в таком микротестировании).

Также существуют и другие методы повышения производительности. Мы могли бы использовать модули ctypes или cffi. Могли бы добавить подсказки типов и воспользоваться Cython для генерации библиотеки, которую могли бы импортировать из Python. При всех этих вариантах требуется учитывать компромиссы, специфичные для каждого конкретного решения.



Мы также измерили расход памяти для каждой реализации функции при помощи приложения GNU time (не путайте со встроенной командой оболочки time). В частности, мы измерили максимальный размер резидентной части памяти (resident set size).

Тогда как в реализациях на чистом Python и Rust максимальные размеры этой части весьма схожи, реализация SciPy/NumPy потребляет ощутимо больше памяти по данному контрольному показателю. Предположительно это связано с дополнительными возможностями, загружаемыми в память при импорте. Как бы то ни было, вызов кода Rust из Python, по-видимому, не привносит серьезных накладных расходов памяти.



Итоги


Мы крайне впечатлены производительностью, достигаемой при вызове Rust из Python. В ходе нашей откровенно краткой оценки реализация на Rust смогла потягаться в производительности с базовой реализацией на C из пакетов SciPy и NumPy. Rust, по-видимому, отлично подходит для эффективной крупномасштабной обработки.

Rust показал не только отличное время выполнения; следует отметить, что и накладные расходы памяти в этих тестах также оказались минимальными. Такие характеристики времени выполнения и использования памяти представляются идеальными для целей масштабирования. Производительность реализаций SciPy и NumPy C FFI определенно сопоставима, но с Rust мы получаем дополнительные плюсы, которых не дают нам C и C++. Гарантии по безопасности памяти и потокобезопасности это очень привлекательное преимущество.

Тогда как C обеспечивает сопоставимое с Rust время выполнения, C как таковой не предоставляет потокобезопасности. Существуют внешние библиотеки, обеспечивающие такой функционал для C, но за правильность их использования полностью отвечает разработчик. Rust следит за проблемами потокобезопасности, например, за возникновением гонок, во время компиляции благодаря реализованной в нем модели владения а стандартная библиотека предоставляет комплект механизмов конкурентности, среди которых каналы, блокировки и умные указатели с подсчетом ссылок.

Мы не призываем портировать SciPy или NumPy на Rust, так как эти библиотеки Python уже хорошо оптимизированы и поддерживаются классными сообществами разработчиков. С другой стороны, мы настоятельно рекомендуем портировать с чистого Python на Rust такой код, который не предоставляется в высокопроизводительных библиотеках. В контексте приложений для data science, используемых для анализа безопасности, Rust представляется конкурентоспособной альтернативой для Python, учитывая его скорость и гарантии безопасности.
Подробнее..

Сортировка выбором

06.07.2020 02:17:13 | Автор: admin
Всем привет. Эту статью я написал специально к запуску курса Алгоритмы и структуры данных от OTUS.



Введение


Сортировка массива является одной из первых серьезных задач, изучаемых в классическом курсе Алгоритмы и структуры данных дисциплины computer science. В связи с этим задачи на написание сортировок и соответствующие вопросы часто встречаются на собеседованиях на позиции стажера или junior разработчика.

Постановка задачи


Традиционно стоит начать изложение решений задачи с ее постановки. Обычно задача сортировки предполагает упорядочивание некоторого массива целых чисел по возрастанию. Но на самом деле, это является некоторым упрощением. Излагаемые в этом разделе алгоритмы можно применять для упорядочивания массива любых объектов, между которыми установлено отношение порядка (то есть про любые два элемента можно сказать: первый больше второго, второй больше первого или они равны). Упорядочивать можно как по возрастанию, так и по убыванию. Мы же воспользуемся стандартным упрощением.

Сортировка выбором


Одной из простейших сортировок является сортировка выбором.

Описание алгоритма


Сортировка массива выбором осуществляется так: массив делится на две части. Одна из частей называется отсортированной, а другая неотсортированной. Алгоритм предполагает проход по всему массиву с тем, чтобы длина отсортированной части стала равна длине всего массива. В рамках каждой итерации мы находим минимум в неотсортированной части массива и меняем местами этот минимум с первым элементом неотсортированной части массива. После чего мы увеличиваем длину отсортированной части массива на единицу. Пример осуществления одной итерации представлен ниже:
1 3 5 | 8 9 6 -> 1 3 5 6 | 9 8

Реализация


Предлагаю посмотреть на реализацию данного алгоритма на языке C:
void choiseSortFunction(double A[], size_t N){    for(size_t tmp_min_index = 0; tmp_min_index < N;                                  tmp_min_index++) {        //ищем минимум        for(size_t k = tmp_min_index + 1; k < N; k++) {            if (A[k] < A[tmp_min_index]) {                double min_value = A[k];                A[k] = A[tmp_min_index];                A[tmp_min_index] = min_value;            }        }    }}


Анализ


Предлагаю проанализировать данный алгоритм. Тело внутреннего цикла само по себе выполняется за O(1), то есть не зависит от размера сортируемого массива. Это означает, что для понимания асимптотики алгоритма необходимо посчитать сколько раз выполняется это тело. На первой итерации внешнего цикла имеют место (n 1) итераций внутреннего цикла. На второй итерации внешнего цикла (n 2) итерации внутреннего цикла. Продолжая это рассуждение и далее, мы приходим к следующему:

$T(n) = (n - 1) * O(1) + (n - 2) * O(1) + ... + O(1) = O((n - 1) + (n - 2) + ... + 1) = O((n - 1) * n / 2) = O(n ^ 2)$



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

Для порядка необходимо оценить еще и дополнительную память, которая требуется для выполнения алгоритма. Здесь все гораздо проще: мы выделяли память только для счетчиков цикла и переменной буфера, позволяющей обменивать 2 элемента массива местами. Поэтому:

$M(n) = O(1)$



В результате анализа мы пришли к тому, что временная сложность алгоритма зависит от размера входного массива квадратично. Поэтому данную сортировку относят к классу квадратичных сортировок. Результат анализа не зависит от содержания массива: он верен в лучшем, в худшем и в среднем случае.

Также важно заметить, что сортировка выбором в такой реализации является устойчивой. Позволю себе напомнить, что сортировка называется устойчивой, если при ее выполнении порядок следования равных элементов не меняется. Это свойство не очень принципиально для такой учебной задачи, как сортировка массива чисел, но если бы мы сортировали какие-то более сложные объекты с установленным отношением порядка это могло бы быть важно. Подобный пример мы можем рассмотреть как-нибудь в следующий раз, когда будем говорить о поразрядной сортировке.

Двусторонняя сортировка выбором


Некоторый интерес может представлять оптимизация алгоритма, реализованного выше: во время прохода по неотсортированной части массива можно параллельно с минимальным элементом искать максимальный. Таким образом после итерации можно уменьшать неотсортированную часть массива не на единицу, а на двойку. Асимптотического улучшения алгоритма не происходит, но тем не менее скорость его выполнения может незначительно увеличиться, число сравнений ведь также увеличивается в два раза.

Рекурсивная сортировка выбором


В качестве упражнения можно попробовать написать алгоритм не с использованием цикла, а с использованием рекурсии. На java это может выглядеть следующим образом:
public static int findMin(int[] array, int index){    int min = index - 1;    if(index < array.length - 1) min = findMin(array, index + 1);    if(array[index] < array[min]) min = index;    return min;}  public static void selectionSort(int[] array){    selectionSort(array, 0);}  public static void selectionSort(int[] array, int left){    if (left < array.length - 1) {        swap(array, left, findMin(array, left));        selectionSort(array, left+1);    }}  public static void swap(int[] array, int index1, int index2) {    int temp = array[index1];    array[index1] = array[index2];    array[index2] = temp;}


Итоги


Мы рассмотрели одну из квадратичных сортировок: сортировку выбором, посмотрели на устойчивую реализацию с использованием цикла, рекурсии, обсудили оптимизацию алгоритма с помощью двустороннего уменьшения неотсортированной части массива. Сортировка является исключительно учебной и не нашла широкого применения на практике.



Если вы хотите подробнее узнать о курсе, приглашаю всех на бесплатный вебинар, который пройдет уже 10 июля.


Подробнее..

Game of Life с битовой магией, многопоточностью и на GPU

03.07.2020 20:16:17 | Автор: admin

Всем привет!


Недавняя статья на Хабре в очередной раз показала неостывающий интерес к игре Жизнь в частности и всевозможным оптимизациям в общем. Статья и комментарии к ней, особенно любопытство к вычислениям на GPU, вдохновили меня на то, чтобы поделиться своими изысканиями на данном поприще и, забегая вперёд, скажу, что повествование пойдёт о расчётах на GPU, битовой магии, многопоточности и огромных полях для игры Жизнь, порядка миллиарда клеток.



О себе


Пару слов о себе и проекте. Вот уже несколько лет моим хобби и pet-проектом является написание игры Жизнь, в ходе которого я разбираюсь в интересующих меня темах. Так, сперва я сделал её с помощью библиотеки glut и прикрутил многопользовательский режим, будучи вдохновлённым архитектурой peer-to-peer. А около двух лет назад решил начать всё с нуля, используя Qt и вычисления на GPU.

Масштабы


Идея нового проекта заключалась в том, чтобы сделать кроссплатформенное приложение, в первую очередь для мобильных устройств, в котором пользователи смогут окунуться в игру Жизнь, познакомиться с разнообразием всевозможных паттернов, наблюдать за причудливыми формами и комбинациями этой игры, выбрав скорость от 1 до 100 мс на шаг и задав размер игрового поля вплоть до 32'768 x 32'768, то есть 1'073'741'824 клетки.

На всякий случай напомню об игре Жизнь. Игра происходит на плоскости, поделённой на множество клеток. Клетки квадратные, соответственно, для каждой клетки есть 8 соседей. Каждая клетка может быть либо живой, либо мёртвой, то есть пустой. В рамках игры пользователь заполняет клетки жизнью, выставляя на поле заинтересовавшие его комбинации клеток паттерны.
Далее процесс шаг за шагом развивается по заданным правилам:
  • в пустой клетке, рядом с которой ровно 3 живые клетки, зарождается жизнь
  • если у живой клетки есть 2 или 3 живых соседки, то эта клетка продолжает жить
  • если у живой клетки меньше 2 или больше 3 живых соседки, клетка умирает

Прозорливый читатель может догадаться, что наивная реализация и близко не справится с таким объёмом вычислений, и поэтому в ход пошли всевозможные оптимизации.

В моей реализации поле зациклено, то есть после 32'767-й клетки вновь следует 0-я. Таким образом, перемещающиеся паттерны способны обогнуть поле и оказаться в точке, где они были размещены изначально. Забавный эффект случается с паттерном планёрное ружьё, когда выпущенные им планёры в конечном итоге врезаются в само ружьё и разрушают его, этакое самоубийство в мире клеточных автоматов:

Gosper glider gun


Архитектура


Что касается реализации, то в первую очередь хочется отметить, что каждая клетка в игровом поле представляет собой всего лишь 1 бит в памяти. Так, при выборе размера игрового поля в памяти выделяется буфер размером n^2 / 8 байт. Это улучшает локальность данных и снижает объём потребляемой памяти, а главное позволяет применить ещё более хитроумные оптимизации, о которых поговорим ниже. Игровое поле всегда квадратное и его сторона всегда степени 2, в частности затем, чтобы без остатка осуществлять деление на 8.

Архитектурно выделяются два слоя, ответственные за вычисления:
  • низкий уровень платформозависимый; на текущий момент существует реализация на Metal API графическое API от Apple позволяет задействовать GPU на устройствах от Apple, а также fallback-реализация на CPU. Одно время существовала версия на OpenCL. Планируется реализация на Vulkan API для запуска на Android
  • высокий уровень кроссплатформенный; по сути класс-шаблонный метод, делегирующий реализацию нужных методов на низкий уровень

Низкий уровень


Задача низкого уровня заключается непосредственно в расчёте состояния игрового поля и устроена следующим образом. В памяти хранятся два буфера n^2 / 8 байт. Первый буфер служит как input текущее состояние игрового поля. Второй буфер как output, в который записывается новое состояние игрового поля в процессе расчётов. По завершении вычислений достаточно сделать swap буферов и следующий шаг игры готов. Такая архитектура позволяет распараллелить расчёты в силу константности input. Дело в том, что каждый поток может независимо обрабатывать клетки игрового поля.

За счёт многопоточности GPU-подход добивается максимальной эффективности. На CPU также происходит распараллеливание, но, соответственно, с гораздо меньшим числом потоков и меньшей эффективностью, что отражено в секции бенчмарков ниже. Сам алгоритм идентичен для всех реализаций и заключается в следующем.

Назовём атомарной операцией расчёт одного байта буфера, хранящего игровое поле, то есть расчёт восьми клеток. Этот расчёт совершается одним потоком и требует информации о соседних восьми байтах. Попробуем схематично изобразить один байт следующим образом, где точка это бит:

[........]


А участок игрового поля с интересующим нас байтом и его восемью соседними байтами изобразим так:

[........][........][........]
[........][********][........]
[........][........][........]


Исходя из рисунка, можно догадаться, что все соседние клетки можно уложить в один uint64_t (назовём его neighbours), а ещё в одном uint8_t (назовём его self), будет храниться информация о соседях в самом обрабатываемом байте.

Рассмотрим на примере расчёт 0-го бита целевого байта. Звёздочкой отметим интересующий бит, а нулём соседние для него биты:

[.......0][00......][........]
[.......0][*0......][........]
[.......0][00......][........]


Пример посложнее. Здесь звёздочкой отмечены 0-й, 3-й и 7-й биты целевого байта и соответствующими числами соседние биты:

[.......0][00333.77][7.......]
[.......0][*03*3.7*][7.......]
[.......0][00333.77][7.......]


Думаю, кто-то из читателей уже догадался, в чём заключается магия. Имея указанную информацию, остаётся лишь составить битовые маски для каждого бита целевого байта и применить их к neighbours и self соответственно. Результатом станут 2 значения, сумма единичных бит которых будет характеризовать число соседей, что можно интерпретировать как правила игры Жизнь: 2 или 3 бита для поддержания жизни в живой клетке и 3 бита для зарождения новой жизни в пустой клетке. В противном случае клетка остаётся/становится пустой.

Описанные вычисления на нижнем уровне происходят каждый шаг игры, и поле всегда обрабатывается целиком вне зависимости от его заполненности и без трюков с мемоизацией а-ля Hashlife.

После заполнения всего выходного буфера игровое поле считается рассчитанным.
Код Metal-шейдера по обработке 1 байта
// Бросается в глаза C++-подобный синтаксис#include <metal_stdlib>#include <metal_integer>using namespace metal;// Вспомогательные функции для вычисления позиции клеткиushort2 pos(uint id) {   return ushort2(id % WIDTH, id / HEIGHT); }uint idx(ushort2 pos) {   return pos.x + pos.y * HEIGHT; }ushort2 loopPos(short x, short y) {   return ushort2((x + WIDTH) % WIDTH, (y + HEIGHT) % HEIGHT); }// Битовые маски для вычисления соседей интересующего битаtemplate<uint Bit> struct Mask {   constant constexpr static uint c_n_e_s_w = 0x70007 << (Bit - 1);   constant constexpr static uint c_nw_ne_se_sw = 0x0;   constant constexpr static uint c_self = 0x5 << (Bit - 1); };template<> struct Mask<0> {   constant constexpr static uint c_n_e_s_w = 0x80030003;   constant constexpr static uint c_nw_ne_se_sw = 0x80000080;   constant constexpr static uint c_self = 0x2; };template<> struct Mask<7> {   constant constexpr static uint c_n_e_s_w = 0xC001C0;   constant constexpr static uint c_nw_ne_se_sw = 0x10100;   constant constexpr static uint c_self = 0x40; };// Для указанного бита функция вычисляет состояние клетки в зависимости от её соседей, применяя соответствующие биту маскиtemplate<uint Bit>uint isAlive(uint self, uint n_e_s_w, uint nw_ne_se_sw) {   /*  [.......0][00333.77][7.......]  [.......0][*03*3.7*][7.......]  [.......0][00333.77][7.......]  */  // До определённой версии в Metal не было 64-битного целого, поэтому составляются две маски  uint neighbours = popcount(Mask<Bit>::c_n_e_s_w & n_e_s_w)     + popcount(Mask<Bit>::c_nw_ne_se_sw & nw_ne_se_sw)     + popcount(Mask<Bit>::c_self & self);   return static_cast<uint>((self >> Bit & 1) == 0     ? neighbours == 3     : neighbours == 2 || neighbours == 3) << Bit;}// Язык Metal даже умеет в шаблонную магиюtemplate<uint Bit>uint calculateLife(uint self, uint n_e_s_w, uint nw_ne_se_sw) {   return isAlive<Bit>(self, n_e_s_w, nw_ne_se_sw)     | calculateLife<Bit - 1>(self, n_e_s_w, nw_ne_se_sw); }template<>uint calculateLife<0>(uint self, uint n_e_s_w, uint nw_ne_se_sw){  return isAlive<0>(self, n_e_s_w, nw_ne_se_sw); }// Главная функция compute-шейдера. На вход подаются два буфера, о которых речь шла выше - константный input и output, а также id - координата целевого байтаkernel void lifeStep(constant uchar* input [[buffer(0)]],                             device uchar* output [[buffer(1)]],                             uint id [[thread_position_in_grid]]) {   ushort2 gid = pos(id * 8);   // Вычисляем соседние байты  uint nw = idx(loopPos(gid.x - 8, gid.y + 1));   uint n  = idx(loopPos(gid.x,     gid.y + 1));   uint ne = idx(loopPos(gid.x + 8, gid.y + 1));   uint e  = idx(loopPos(gid.x + 8, gid.y    ));   uint se = idx(loopPos(gid.x + 8, gid.y - 1));   uint s  = idx(loopPos(gid.x    , gid.y - 1));   uint sw = idx(loopPos(gid.x - 8, gid.y - 1));   uint w  = idx(loopPos(gid.x - 8, gid.y    ));   // Вычисляем байт с целевым битом  uint self = static_cast<uint>(input[id]);   // Подготавливаем битовые маски с соседями  // north_east_south_west  uint n_e_s_w = static_cast<uint>(input[n >> 3]) << 0 * 8     | static_cast<uint>(input[e >> 3]) << 1 * 8     | static_cast<uint>(input[s >> 3]) << 2 * 8     | static_cast<uint>(input[w >> 3]) << 3 * 8;   // north-west_north-east_south-east_south-west  uint nw_ne_se_sw = static_cast<uint>(input[nw >> 3]) << 0 * 8     | static_cast<uint>(input[ne >> 3]) << 1 * 8     | static_cast<uint>(input[se >> 3]) << 2 * 8     | static_cast<uint>(input[sw >> 3]) << 3 * 8;     // В этой строчке рассчитываются все 8 клеток обрабатываемого байта  output[id] = static_cast<uchar>(calculateLife<7>(self, n_e_s_w, nw_ne_se_sw)); };


Высокий уровень


После получения сигнала с нижнего уровня о том, что расчёт окончен, на высоком уровне происходит копирование всего буфера с целью обеспечения потокобезопасного чтения данных для отрисовки и возможного сохранения состояния игры при выходе.

Отрисовка клеток выполняется средствами Qt, а именно посредством заполнения пикселей в QImage. Интерфейс выполнен в QML. Пиксели заполняются лишь для небольшой области видимого игроку игрового поля. Таким образом удаётся избежать лишних затрат ресурсов на отрисовку.

Процесс вычисления как на нижнем, так и на верхнем уровне происходит в разных потоках. На верхнем уровне обрабатывается ввод новых клеток, составляющих паттерн, который выбрал пользователь. Интересно, что это единственная логика, которая использует мьютекс. Для всех других операций синхронизации как на верхнем, так и на нижнем уровне и между ними используются атомарные переменные.

Бенчмарки


Результаты замеров скорости работы игры для разных машин в зависимости от размеров игрового поля приведены в таблицах. Полученные значения в таблицах указаны в миллисекундах. При проведении тестов игровое поле заполнялось жизнью на 50% с помощью генератора случайных чисел.

В таблицах есть замеры скорости работы нижнего и верхнего уровня, причём берутся минимальные значения. А для более общей картины приведены замеры скорости вычисления 100 шагов игры и среднего времени выполнения шага. Последнее более показательно, так как включает в себя время на отрисовку и прочие сопутствующие операции.

MacBook Pro 2014
Processor 2,6 GHz Dual-Core Intel Core i5
Memory 8 GB 1600 MHz DDR3
Graphics Intel Iris 1536 MB

GPU реализация
1024 2048 4096 8192 16384 32768
Низкий уровень (min) 0 0 2 9 43 170
Высокий уровень (min) 0 0 0 1 12 55
100 шагов 293 446 1271 2700 8603 54287
Время на шаг (avg) 3 4 13 27 86 542

CPU реализация
1024 2048 4096 8192 16384 32768
Низкий уровень (min) 3 25 117 552 2077 21388
Высокий уровень (min) 0 0 0 1 4 51
100 шагов 944 3894 15217 65149 231260 -
Время на шаг (avg) 9 39 152 651 2312 -

MacBook Pro 2017
Processor 2.8 GHz Intel Core i7
Memory 16 GB 2133 MHz LPDDR3
Graphics Intel HD Graphics 630 1536 MB

GPU реализация
1024 2048 4096 8192 16384 32768
Низкий уровень (min) 0 0 0 2 8 38
Высокий уровень (min) 0 0 0 0 3 13
100 шагов 35 67 163 450 1451 5886
Время на шаг (avg) 0 1 2 5 15 59

CPU реализация
1024 2048 4096 8192 16384 32768
Низкий уровень (min) 1 7 33 136 699 2475
Высокий уровень (min) 0 0 0 0 3 18
100 шагов 434 1283 4262 18377 79656 264711
Время на шаг (avg) 4 13 43 184 797 2647

Видно, что на стареньком макбуке процессор совсем не справляется с огромным игровым полем и приложение становится неюзабельным. Даже процессор нового макбука едва вытягивает такой объём вычислений. Зато видеокарта значительно превосходит по производительности процессор как на старом, так и на новом железе. С использованием GPU новый макбук предлагает вполне комфортный пользовательский опыт даже на огромных игровых пространствах.

Итог


На текущий момент игра находится в стадии разработки интерфейсов и в первую очередь затачивается под мобильные интерфейсы, а потому на компьютерах управление хромает.

В отличие от оригинальной статьи я не ставил целью провести бенчмарк различных оптимизаций, поэтому сравнивать две статьи один к одному нельзя, зато материал отлично раскрывает тему сравнения потенциала GPU и CPU со значительным превосходством первого.

В будущем хочется провести бенчмарки на мобильных устройствах. Проект находится на ранней стадии разработки, а потому Apple-Developer аккаунта для проведения таких тестов сейчас у меня нет.

Спасибо за внимание! Буду рад любым комментариям к статье и к проекту:
Код на GitHub.

Код Metal реализации нижнего уровня

Код CPU реализации нижнего уровня

Код верхнего уровня
Подробнее..

Как определить размер переменных во время выполнения Go-программы

27.06.2020 22:14:45 | Автор: admin
Аннотация: в заметке рассматривается один из способов анализа потребления памяти компонентами Go-приложения.
Зачастую в памяти программы хранятся структуры данных, которые изменяют свой размер динамически, по ходу работы программы. Примером такой структуры может быть кэш данных или журнал работы программы или данные, получаемые от внешних систем. При этом может возникнуть ситуация, когда потребление памяти растёт, возможностей оборудования не хватает, а конкретный механизм утечки не ясен.
Основным способом профилирования Go-приложений является подключение инструмента pprof из пакета net/http/pprof. В результате можно получить таблицу или граф с распределением памяти в работающей программе. Но использование этого инструмента требует очень больших накладных расходов и может быть неприменимо, особенно если вы не можете запустить несколько экземпляров программы с реальными данными.
В таком случае возникает желание измерить потребление памяти объектами программы по запросу, чтобы, например, отобразить статистику системы или передать метрики в систему мониторинга. Однако средствами языка это в общем случае невозможно. В Go нет инструментов для определения размера переменных во время работы программы.
Поэтому я решил написать небольшой пакет, который предоставляет такую возможность. Основным инструментом является рефлексия (пакет reflection). Всех интересующихся вопросом такого профилирования приложения приглашаю к дальнейшему чтению.


Сначала нужно сказать пару слов по поводу встроенных функций
unsafe.Sizeof(value)
и
reflect.TypeOf(value).Size()

Эти функции эквивалентны и зачастую в Интернете именно их рекомендуют для определения размера переменных. Но эти функции возвращают не размер фактической переменной, а размер в байтах для контейнера переменной (грубо размер указателя). К примеру, для переменной типа int64 эти функции вернут корректный результат, поскольку переменная данного типа содержит фактическое значение, а не ссылку на него. Но для типов данных, содержащих в себе указатель на фактическое значение, вроде слайса или строки, эти функции вернут одинаковое для всех переменных данного типа значение. Это значение соответствует размеру контейнера, содержащего ссылку на данные переменной. Проиллюстрирую примером:
func main() {s1 := "ABC"s2 := "ABCDEF"arr1 := []int{1, 2}arr2 := []int{1, 2, 3, 4, 5, 6}fmt.Printf("Var: %s, Size: %v\n", s1, unsafe.Sizeof(s1))fmt.Printf("Var: %s, Size: %v\n", s2, unsafe.Sizeof(s2))fmt.Printf("Var: %v, Size: %v\n", arr1, reflect.TypeOf(arr1).Size())fmt.Printf("Var: %v, Size: %v\n", arr2, reflect.TypeOf(arr2).Size())}

В результате получим:
Var: ABC, Size: 16Var: ABCDEF, Size: 16Var: [1 2], Size: 24Var: [1 2 3 4 5 6], Size: 24

Как видите, фактический размер переменной не вычисляется.
В стандартной библиотеке есть функция binary.Size() которая возвращает размер переменной в байтах, но только для типов фиксированного размера. То есть если в полях вашей структуры встретится строка, слайс, ассоциативный массив или просто int, то функция не применима. Однако именно эту функция я взял за основу пакета size, в котором попытался расширить возможности приведённого выше механизма на типы данных без фиксированного размера.
Для определения размера объекта во время работы программы необходимо понять его тип, вместе с типами всех вложенных объектов, если это структура. Итоговая структура, которую необходимо анализировать, в общем случае представляется в виде дерева. Поэтому для определения размера сложных типов данных нужно использовать рекурсию.
Таким образом вычисление объёма потребляемой памяти для произвольного объекта представляется следующим образом:
  • алгоритм определение размера переменной простого (не составного) типа;
  • рекурсивный вызов алгоритма для элементов массивов, полей структур, ключей и значений ассоциативных массивов;
  • определение бесконечных циклов;

Чтобы определить фактический размер переменной простого типа (не массива или структуры), можно использовать приведённую выше функцию Size() из пакета reflection. Эта функция корректно работает для переменных, содержащих фактическое значение. Для переменных, являющихся массивами, строками, т.е. содержащих ссылки на значение нужно пройтись по элементам или полям и вычислить значение каждого элемента.
Для анализа типа и значения переменной пакет reflection упаковывает переменную в пустой интерфейс (interface{}). В Go пустой интерфейс может содержать любой объект. Кроме того, интерфейс в Go представлен контейнером, содержащим два поля: тип фактического значения и ссылку на фактическое значение.
Именно отображение анализируемого значения в пустой интерфейс и обратно послужило основанием для названия самого приёма reflection.
Для лучшего понимания работы рефлексии в Go рекомендую статью Роба Пайка в официальном блоге Go. Перевод этой статьи был на Хабре.
В конечном итоге был разработан пакет size, который можно использовать в своих программах следующим образом:
package mainimport ("fmt""github.com/DmitriyVTitov/size")func main() {a := struct {a intb stringc boold int32e []bytef [3]int64}{a: 10,                    // 8 bytesb: "Text",                // 4 bytesc: true,                  // 1 byted: 25,                    // 4 bytese: []byte{'c', 'd', 'e'}, // 3 bytesf: [3]int64{1, 2, 3},     // 24 bytes}fmt.Println(size.Of(a))}// Output: 44

Замечания:
  • На практике вычисление размера структур объёма около 10 ГБайт с большой вложенностью занимает 10-20 минут. Это результат того, что рефлексия довольно дорогая операция, требующая упаковки каждой переменной в пустой интерфейс и последующий анализ (см. статью по ссылке выше).
  • В результате сравнительно невысокой скорости, пакет следует использовать для примерного определения размера переменных, поскольку в реальной системе за время анализа большой структуры фактические данные наверняка успеют измениться. Либо обеспечивайте исключительный доступ к данным на время расчёта с помощью мьютекса, если это допустимо.
  • Программа не учитывает размер контейнеров для массивов, интефейсов и ассоциативных массивов (это 24 байта для массива и слайса, 8 байт для map и interface). Поэтому, если у вас большое количество таких элементов небольшого размера, то потери будут существенными.
Подробнее..

Капля в море Запуск Drupal в Kubernetes

26.06.2020 20:05:42 | Автор: admin

image


Я работаю в компании Initlab. Мы специализируемся на разработке и поддержке Drupal проектов. У нас есть продукт для быстрого создания Ecommerce решений, основанный на Drupal. В 2019 году мы начали решать задачу построения масштабируемой и отказоустойчивой инфраструктуры для нашего продукта.


В качестве платформы для инфраструктуры был выбран Kubernetes. В этой статье разберем особенности Drupal и подходы, с помощью которых можно запускать Drupal в кластере Kubernetes.


Часть статьи является переводом статьи Джеффа Герлинга. Со своей стороны добавили более подробное описание вариантов запуска баз данных и отказоустойчивого хранилища в кластере.


Drupal и Docker


image


Drupal и Docker могут работать вместе и хорошим примером тут служат локальные среды разработки, построенные вокруг Docker, такие как Docksal, Ddev, Lando и DrupalVM. Но локальные среды разработки, в которых кодовая база Drupal в основном монтируется, как том в контейнер Docker, сильно отличаются от рабочей среды, где цель состоит в том, чтобы вместить как можно большую часть кода в stateless контейнер.


В Kubernetes все, или почти все приложения вынуждены работать, как 12-факторное приложение и при развертывании Drupal в Kubernetes необходимо это учитывать.


Что такое 12 Factor Apps?


image


На базовом уровне 12-факторное приложение это приложение, которое легко развертывается в масштабируемой, широкодоступной и облачной среде.


Многим приложениям нужны базы данных для учетных записей пользователей, общая файловая система для загруженных или сгенерированных файлов, совместно используемых несколькими экземплярами, а также другие элементы, способные затруднить развертывание приложений масштабируемым и гибким способом.


Но 12-факторное приложение это приложение, которое облегчает развертывание и управление в масштабируемой среде:


  • Сохраняет всю свою конфигурацию в декларативном формате, так что нет никаких ручных или неавтоматизированных шагов установки.
  • Не слишком сильно связано с одним типом операционной системы или со сложностями настройки алгоритмического языка и среды выполнения.
  • Может масштабироваться от одного до нескольких реплик с минимальным (в идеале нулевым) вмешательством.
  • Практически одинаково как в производственной, так и в непроизводственной среде.
  • 12-факторному приложению могут потребоваться базы данных, общие файловые системы и другие элементы, но в идеале оно может работать так, что сводит к минимуму затраты при работе с этими элементами.

Условия размещения Drupal в Kubernetes


Чтобы превратить приложение или веб-сайт на Drupal в более безликое 12-факторное приложение, необходимо соблюсти пять главных условий:


  1. База данных должна быть постоянной. Данные должны сохраняться между запусками контейнеров.
  2. Каталог файлов Drupal должен быть постоянным, масштабируемым и общим для всех веб-контейнеров.
  3. Установка Drupal и агрегация CSS и JS должны иметь возможность сохранять данные в каталогах с файлами Drupal-сайта.
  4. Настройки Drupal settings.php и другие важные настройки должны настраиваться установкой значений переменных среды.
  5. Задание cron в Drupal следует запускать через Drush, чтобы иметь возможность запускать его наиболее эффективным и масштабируемым образом.

Выполнение условий размещения Drupal в Kubernetes


1. База данных


Существует несколько способов решить задачу сохранения данных при перезапуске контейнеров в Kubernetes. Эту задачу необходимо решить любому веб-сервису, использующему базу данных. Рассмотрим эти варианты.


Управляемый сервис баз данных


Самый простой и наиболее часто применяемый способ использование сервиса управляемых баз данных: AWS RDS Aurora или Cloud SQL в Google Cloud. В этом случае, мы можем подключить свой сайт на Drupal напрямую к управляемой базе данных.


Недостатком данного подхода является то, что он может быть дороже, чем решение с самостоятельным управлением, и не всегда применим из-за особенностей инфраструктуры. Преимущество управляемых баз данных в том что это надежное решение и не требуется тратить ресурсы на администрирование.


Docker образ mysql


Использовать базы данных внутри кластера Kubernetes можно, организовав отдельный контейнер для данных с помощью Docker. Это решение по стоимости дешевле, сохраняет переносимость и хорошо работает с небольшими сайтами и приложениями.
Но если необходимо иметь высокодоступные базы данных, реплицируемые на запись/чтение данных внутри Kubernetes, потребуется сделать гораздо больше работы.


MySQL Operator


Проект MySQL Operator предназначен для упрощения процесса размещения базы в кластере. Не работает в последних версиях kubernetes, начиная с 1.16. Кроме того, в каждой версии Kubernetes могут вноситься значительные изменения в API, из-за чего проект требуется адаптировать к новым версиям, что разработчики не всегда успевают делать.


Percona XtraDB Cluster Operator


Альтернатива MySQL Operator это решение Percona XtraDB Cluster Operator. Percona XtraDB Cluster объединяет в себе Percona Server для MySQL, работающий с механизмом хранения XtraDB, и Percona XtraBackup с библиотекой Galera, для обеспечения синхронной multi-master репликации.


image


Балансировка нагрузки выполняется с помощью демона ProxySQL, который принимает входящий трафик от клиентов MySQL и перенаправляет его на серверы MySQL. ProxySQL эффективно управляет рабочей нагрузкой базы данных по сравнению с другими балансировщиками нагрузки, которые не поддерживают SQL.


Helm chart для MariaDB


Более простым для запуска базы данных в кластере Kubernetes может быть использование Helm chart для MariaDB, которое позволяет развернуть в Kubernetes реплицированный кластер MariaDB. В данном кластере развертывается один master, а остальные slave контейнеры, с которых возможно чтение данных. В отличии от multi-master репликации, при отказе master контейнера будет остановке сервиса, на время запуска нового мастера.


PostgreSQL


Если рассматривать СУБД PostgreSQL, то существует проект Stolon облачный менеджер для обеспечения высокой доступности кластера PostgreSQL.


При деплое баз данных под MySQL или PostgreSQL, в Kubernetes, нужно где-то сохранять файлы с фактическими данных. Наиболее распространенный способ подключить Kubernetes PV (Persistent Volume) к контейнеру MySQL.


Итак, придется решить проблему запуска постоянной базы данных в кластере Kubernetes, если требуется использовать полностью облачную среду.


2. Каталог файлов Drupal


Drupal должен иметь возможность записи в файловую систему внутри кодовой базы в нескольких случаях:


  • Когда во время установки Drupal перезаписывается файл settings.php.
  • Когда во время установки Drupal не проходит предварительную проверку установщика, если каталог сайта (например sites/default) не доступен для записи.
  • Если при создании кеша тем Drupal требуется доступ на запись в каталог публичных файлов, чтобы он мог хранить сгенерированные файлы Twig и PHP.
  • Если используется агрегация CSS и JS, Drupal требуется доступ на запись в каталог публичных файлов, чтобы он мог хранить сгенерированные файлы js и css.

Существует несколько вариантов решения этих задач.


NFS


Самое простое решение всех этих задач, позволяющее Drupal масштабировать и запускать более одного экземпляра Drupal это использовать NFS для папки файлов Drupal (например sites/default/files). Однако легче не значит лучше. У NFS есть свои особенности:


  • NFS имеет проблемы с производительностью блокировок файлов при нагрузках.
  • NFS может быть медленным для определенных типов доступа к файлам.

Облачное хранилище совместимое с S3


Можно использовать сервис хранения типа Amazon S3 в качестве общедоступной файловой системы для своего Drupal сайта, что снизит затраты на поддержку хранилища и повысит его надежность. Для интеграции S3 хранилища с drupal можно использовать, например, модуль S3FS.


Распределенные файловые системы


Также можно использовать распределенные файловые системы, такие как Gluster или Ceph. Однако тут увеличивается сложность поддержки таких решений, которые можно решить в Kubernetes с помощью проекта rook.


Проект Rook


image


Rook "превращает" распределенные системы хранения в самоуправляемые, самомасштабируемые, самовосстанавливающиеся сервисы хранения. Он автоматизирует задачи администратора хранилища: развертывание, загрузку, настройку, предоставление, масштабирование, обновление, миграцию, аварийное восстановление, мониторинг и управление ресурсами.


Rook использует возможности платформы Kubernetes для предоставления своих услуг: облачное управление контейнерами, планирование и оркестрация.


Rook управляет несколькими решениями для хранения, предоставляя общую платформу для всех из них. Rook гарантирует, что все они будут хорошо работать в Kubernetes:


  • Ceph
  • EdgeFS
  • CockroachDB
  • Cassandra
  • NFS
  • Yugabyte DB

3. Установка Drupal


image


Сделать файл settings.php доступным для записи во время установки более сложная задача, особенно если вам нужно установить Drupal, а затем развернуть новый образ контейнера ( что произойдет со всеми записанными значениями settings.php? Они будут удалены при перезапуске контейнера! ). Поэтому решение данной задачи получило отдельный пункт описания особенностей запуска Drupal в Kubernetes.


Пользователи могут устанавливать Drupal через веб-интерфейс мастера установки или автоматически через Drush. В любом случае Drupal во время установки, если еще не установлены все правильные переменные в settings.php, добавляет некоторые дополнительные конфигурации к файлу sites/default/settings.php или создает этот файл, если его не существует.


Следующие параметры должны быть предварительно установлены в settings.php, чтобы Drupal автоматически пропускал экран установки и не требовал записи дополнительных конфигураций в файл settings.php:


// Config sync directory.$config_directories['sync'] = '../config/sync';// Hash salt.$settings['hash_salt'] = getenv('DRUPAL_HASH_SALT');// Disallow access to update.php by anonymous users.$settings['update_free_access'] = FALSE;// Other helpful settings.$settings['container_yamls'][] = $app_root . '/' . $site_path . '/services.yml';// Database connection.$databases['default']['default'] = [  'database' => getenv('DRUPAL_DATABASE_NAME'),  'username' => getenv('DRUPAL_DATABASE_USERNAME'),  'password' => getenv('DRUPAL_DATABASE_PASSWORD'),  'prefix' => '',  'host' => getenv('DRUPAL_DATABASE_HOST'),  'port' => getenv('DRUPAL_DATABASE_PORT'),  'namespace' => 'Drupal\Core\Database\Driver\mysql',  'driver' => 'mysql',]

В данном примере используются переменные окружения (например: DRUPAL_HASH_SALT) с функцией PHP getenv() вместо "жестко" заданных значений в файле. Это позволяет контролировать настройки, которые Drupal использует как при установки, так и для новых контейнеров, которые запускаются после установки Drupal.


В файле Docker Compose, чтобы передать переменные, указываются директива environment для контейнера web/Drupal следующим образом:


services:  drupal:    image: drupal:latest    container_name: drupal    environment:    DRUPAL_DATABASE_HOST: 'mysql'    [...]    DRUPAL_HASH_SALT: 'fe918c992fb1bcfa01f32303c8b21f3d0a0'

А в Kubernetes, когда создаете Drupal Deployment, передать переменные среды можно с помощью envFrom и ConfigMap (предпочтительно), также можно напрямую передавать переменные среды в спецификации контейнера:


---apiVersion: extensions/v1beta1kind: Deploymentmetadata:  name: drupal  [...]spec:  template:    [...]    spec:    containers:        - image: {{ drupal_docker_image }}        name: drupal        envFrom:        - configMapRef:            name: drupal-config        env:            - name: DRUPAL_DATABASE_PASSWORD            valueFrom:                secretKeyRef:                name: mysql-pass                key: drupal-mysql-pass

Приведенный выше фрагмент манифеста предполагает, что уже в kubernetes есть ConfigMap с именем drupal-config содержащей все DRUPAL_* переменные, кроме пароля к базе данных, а также секрет, названный mysql-pass, с паролем в ключе drupal-mysql-pass.


На этом этапе, если создаете новую среду с помощью Docker Compose локально или в кластере Kubernetes, можете перейти к установщику пользовательского интерфейса или использовать Drush. При этом не требуется вводить учетные данные базы данных, поскольку эти и другие необходимые переменные уже предварительно настроены.


4. Конфигурация Drupal, управляемая средой


Также может быть ряд других параметров конфигурации, которыми требуется управлять, используя переменные среды. Drupal еще не имеет механизма для этого, поэтому необходимо встроить такие настройки в код, используя getenv().


5. Выполнение заданий Cron Drupal в Kubernetes


image


Чтобы отслеживать любые проблемы с cron отдельно от обычного веб-трафика и быть уверенным, что выполнение заданий cron не мешает обычным посещениям страниц следует запускать cron через внешний процесс вместо того, чтобы запускать его при посещениях интерфейсной страницы (именно так Drupal по умолчанию запускает cron).


Внутри Kubernetes не нужно настраивать crontab ни на один из узлов Kubernetes, работающих с сайтами Drupal. И хотя может быть внешняя система, например Jenkins, запускающая задания cron на Drupal сайте, гораздо проще просто запускать Cron Drupal как Kubernetes CronJob, в идеале в том же пространстве имен Kubernetes, что и Drupal.


Самый надежный способ запуска Drupal cron через Drush, но запуск отдельного контейнера Drush через CronJob означает, что CronJob должен запланировать сложный контейнер, работающий как минимум на PHP и Drush. Поды CronJob должны быть максимально легкими, чтобы их можно было планировать на любом системном узле и запускать очень быстро (даже если ваш контейнер Drupal еще не был загружен на этот конкретный узел Kubernetes).


Cron Drupal поддерживает запуск путем посещения URL-адреса, и это самый простой вариант для работы с Kubernetes. Пример настройки CronJob:


---apiVersion: batch/v1beta1kind: CronJobmetadata:  name: drupal-cron  namespace: my-drupal-sitespec:  schedule: "*/1 * * * *"  concurrencyPolicy: Forbid  jobTemplate:    spec:    template:        spec:        containers:        - name: drupal-cron            image: byrnedo/alpine-curl:0.1            args:            - -s            - http://www.my-drupal-site.com/cron/cron-url-token        restartPolicy: OnFailure

URL drupal_cron_url зависит от сайта и можно его найти, посетив страницу /admin/config/system/cron. Убедитесь, что в настройках cron для Run cron every установлено значение Never, чтобы cron запускался только через CronJob Kubernetes.


Можно использовать byrnedo/alpine-curl образ докера, который является чрезвычайно легким всего 5 или 6 МБ так как он основан на Alpine Linux. Большинство других контейнеров, которые есть на базе Ubuntu или Debian, имеют размер, по крайней мере, 30-40 МБ (так что они будут использовать гораздо больше времени, чтобы загрузить первый раз, когда CronJob запускается на новом узле).


Заключение


Мы рассмотрели особенности размещения Drupal проектов в Kubernetes. В продолжении этой статьи детально рассмотрим как мы запустили наш продукт в кластере Kubernetes.

Подробнее..

System.Threading.Channels высокопроизводительный производитель-потребитель и асинхронность без алокаций и стэк дайва

07.07.2020 14:13:48 | Автор: admin
И снова здравствуй. Какое-то время назад я писал о другом малоизвестном инструменте для любителей высокой производительности System.IO.Pipelines. По своей сути, рассматриваемый System.Threading.Channels (в дальнейшем каналы) построен по похожим принципам, что и Пайплайны, решает ту же задачу Производитель-Потребитель. Однако имеет в разы более простое апи, которое изящно вольется в любого рода enterprise-код. При этом использует асинхронность без алокаций и без stack-dive даже в асинхронном случае! (Не всегда, но часто).



Оглавление




Введение


Задача Производитель/Потребитель встречается на пути программистов довольно часто и уже не первый десяток лет. Сам Эдсгер Дейкстра приложил руку к решению данной задачи ему принадлежит идея использования семафоров для синхронизации потоков при организации работы по принципу производитель/потребитель. И хотя ее решение в простейшем виде известно и довольно тривиально, в реальном мире данный шаблон (Производитель/Потребитель) может встречаться в гораздо более усложненном виде. Также современные стандарты программирования накладывают свои отпечатки, код пишется более упрощенно и разбивается для дальнейшего переиспользования. Все делается для понижения порога написания качественного кода и упрощения данного процесса. И рассматриваемое пространство имен System.Threading.Channels очередной шаг на пути к этой цели.

Какое-то время назад я рассматривал System.IO.Pipelines. Там требовалось более внимательная работа и глубокое осознание дела, в ход шли Span и Memory, а для эффективной работы требовалось не вызывать очевидных методов (чтобы избежать лишних выделений памяти) и постоянно думать в байтах. Из-за этого программный интерфейс Пайплайнов был нетривиален и не интуитивно понятен.

В System.Threading.Channels пользователю представляется гораздо более простое api для работы. Стоит упомянуть, что несмотря на простоту api, данный инструмент является весьма оптимизированным и на протяжении своей работы вполне вероятно не выделит память. Возможно это благодаря тому, что под капотом повсеместно используется ValueTask, а даже в случае реальной асинхронности используется IValueTaskSource, который переиспользуется для дальнейших операций. Именно в этом заключается весь интерес реализации Каналов.

Каналы являются обобщенными, тип обобщения, как несложно догадаться тип, экземпляры которого будут производиться и потребляться. Интересно то, что реализация класса Channel, которая помещается в 1 строку (источник github):

namespace System.Threading.Channels{    public abstract class Channel<T> : Channel<T, T> { }}

Таким образом основной класс каналов параметризован 2 типами отдельно под канал производитель и канал потребитель. Но для реализованых каналов это не используется.
Для тех, кто знаком с Пайплайнами, общий подход для начала работы покажется знакомым. А именно. Мы создаем 1 центральный класс, из которого вытаскиваем отдельно производителей(CannelWriter) и потребителей(ChannelReader). Несмотря на названия, стоит помнить, что это именно производитель/потребитель, а не читатель/писатель из еще одной классической одноименной задачи на многопоточность. ChannelReader изменяет состояние общего channel (вытаскивает значение), которое более становится недоступно. А значит он скорее не читает, а потребляет. Но с реализацией мы ознакомимся позже.

Начало работы. Channel


Начало работы с каналами начинается с абстрактного класса Channel<T> и статического класса Channel, который создает наиболее подходящую реализацию. Далее из этого общего Channel можно получать ChannelWriter для записи в канал и ChannelReader для потребления из канала. Канал является хранилищем общей информации для ChannelWriter и ChannelReader, так, именно в нем хранятся все данные. А уже логика их записи или потребления рассредоточения в ChannelWriter и ChannelReader, Условно каналы можно разделить на 2 группы безграничные и ограниченные. Первые более простые по реализации, в них можно писать безгранично (пока память позволяет). Вторые же ограничены неким максимальным значением количества записей.

Здесь вытекает немного разная природа асинхронности. В неограниченных каналах операция записи всегда будет завершаться синхронно, нет ничего, что могло бы остановить от записи в канал. Для ограниченных каналов ситуация иная. При стандартном поведении (которое можно заменить) операция записи будет завершаться синхронно до тех пор пока в канале есть место для новых экземпляров. Как только канал заполнен, операция записи не завершится, пока не освободится место (после того, как потребитель потребил потребляемое). Поэтому здесь операция будет реально асинхронной со сменой потоков и сопутствующими изменениями (или без смены, что будет описано чуть позже).

Поведения читателей по большей части одинаково если в канале есть что-то, то читатель просто читает это и завершается синхронно. Если ничего нет, то ожидает пока кто-то что-то запишет.

Статический класс Channel содержит 4 метода для создания вышеперечисленных каналов:

Channel<T> CreateUnbounded<T>();Channel<T> CreateUnbounded<T>(UnboundedChannelOptions options);Channel<T> CreateBounded<T>(int capacity);Channel<T> CreateBounded<T>(BoundedChannelOptions options);

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

UnboundedChannelOptions содержит 3 свойства, значение которых по умолчанию false:

  1. AllowSynchronousContinuations просто сумасводящая опция, которая позволяет выполнить продолжение асинхронной операции тому, кто ее разблокирует. А теперь по-простому. Допустим, мы писали в заполненный канал. Соответственно, операция прерывается, поток освобождается, а продолжение будет выполнено по завершению на новом потоке из пула. Но если включить эту опцию, продолжение выполнит тот, кто разблокирует операцию, то есть в нашем случае читатель. Это серьезно меняет внутреннее поведение и позволяет более экономно и производительно распоряжаться ресурсами, ведь зачем нам слать какие-то продолжения в какие-то потоки, если мы можем сами его выполнить;
  2. SingleReader указывает, что будет использоваться один потребитель. Опять же, это позволяет избавиться от некоторой лишней синхронизации;
  3. SingleWriter то же самое, только для писателя;

BoundedChannelOptions содержит те же 3 свойства и еще 2 сверху

  1. AllowSynchronousContinuations то же;
  2. SingleReader то же;
  3. SingleWriter то же;
  4. Capacity количество вмещаемых в канал записей. Данный параметр также является параметром конструктора;
  5. FullMode перечисление BoundedChannelFullMode, которое имеет 4 опции, определяет поведение при попытке записи в заполненный канал:
    • Wait ожидает освобождения места для завершения асинхронной операции
    • DropNewest записываемый элемент перезаписывает самый новый из существующих, завершается синхронно
    • DropOldest записываемый элемент перезаписывает самый старый из существующих завершается синхронно
    • DropWrite записываемый элемент не записывается, завершается синхронно


В зависимости от переданных параметров и вызванного метода будет создана одна из 3 реализаций: SingleConsumerUnboundedChannel, UnboundedChannel, BoundedChannel. Но это не столь важно, ведь пользоваться каналом мы будем через базовый класс Channel<TWrite, TRead>.

У него есть 2 свойства:

  • ChannelReader<TRead> Reader { get; protected set; }
  • ChannelWriter<TWrite> Writer { get; protected set; }

А также, 2 оператора неявного приведения типа к ChannelReader<TRead> и ChannelWriter<TWrite>.

Пример начала работы с каналами:

Channel<int> channel = Channel.CreateUnbounded<int>();//Можно делать такChannelWriter<int> writer = channel.Writer;ChannelReader<int> reader = channel.Reader; //Или такChannelWriter<int> writer = channel;ChannelReader<int> reader = channel;

Данные хранятся в очереди. Для 3 типов используются 3 разные очереди ConcurrentQueue<T>, Deque<T> и SingleProducerSingleConsumerQueue<T>. На этом моменте мне показалось, что я устарел и пропустил кучу новых простейших коллекций. Но спешу огорчить они не для всех. Помечены internal, так что использовать их не получится. Но если вдруг они понадобятся на проде их можно найти здесь (SingleProducerConsumerQueue) и здесь (Deque). Реализация последней весьма проста. Советую ознакомится, ее очень быстро можно изучить.

Итак, приступим к изучению непосредственно ChannelReader и ChannelWriter, а также интересных деталей реализации. Они все сводятся к асинхронности без выделений памяти с помощью IValueTaskSource.

ChannelReader потребитель


При запросе объекта потребителя возвращается одна из реализаций абстрактного класса ChannelReader<T>. Опять же в отличие от Пайплайнов АПИ несложное и методов немного. Достаточно просто знать список методов, чтобы понять, как использовать это на практике.

Методы:

  1. Виртуальное get-only свойство Task Completion { get; }
    Обьект типа Task, который завершается, когда закрывается канал;
  2. Виртуальное get-only свойство int Count { get; }
    Тут сделает заострить внимание, что возвращается текущее количество доступных для чтения объектов;
  3. Виртуальное get-only свойство bool CanCount { get; }
    Показывает, доступно ли свойство Count;
  4. Абстрактный метод bool TryRead(out T item)
    Пытается потребить объект из канала. Возвращает bool, показывающий, получилось ли у него прочитать. Результат помещается в out параметр (или null, если не получилось);
  5. Абстрактный ValueTask<bool> WaitToReadAsync(CancellationToken cancellationToken = default)
    Возвращается ValueTask со значением true, когда в канале появятся доступные для чтения данные, до тех пор задача не завершается. Возвращает ValueTask со значением false, когда канал закрывается(данных для чтения больше не будет);
  6. Виртуальный метод ValueTask<T> ReadAsync(CancellationToken cancellationToken = default)
    Потребляет значение из канала. Если значение есть, возвращается синхронно. В противном случае асинхронно ждет появления доступных для чтения данных и возвращает их.

    У данного метода в абстрактном классе есть реализация, которая основана на методах TryRead и WaitToReadAsync. Если опустить все инфраструктурные нюансы (исключения и cancelation tokens), то логика примерно такая попытаться прочитать объект с помощью TryRead. Если не удалось, то в цикле while(true) проверять результат метода WaitToReadAsync. Если true, то есть данные есть, вызвать TryRead. Если TryRead получается прочитать, то вернуть результат, в противном случае цикл по новой. Цикл нужен для неудачных попыток чтения в результате гонки потоков, сразу много потоков могут получить завершение WaitToReadAsync, но объект будет только один, соответственно только один поток сможет прочитать, а остальные уйдут на повторный круг.
    Однако данная реализация, как правило, переопределена на что-то более завязанное на внутреннем устройстве.


ChannelWriter производитель


Все аналогично потребителю, так что сразу смотрим методы:

  1. Виртуальный метод bool TryComplete(Exception? error = null)
    Пытается пометить канал как завершенный, т.е. показать, что в него больше не будет записано данных. В качестве необязательного параметра можно передать исключение, которое вызвало завершение канала. Возвращает true, если удалось завершить, в противном случае false (если канал уже был завершен или не поддерживает завршение);
  2. Абстрактный метод bool TryWrite(T item)
    Пытается записать в канал значение. Возвращает true, если удалось и false, если нет
  3. Абстрактный метод ValueTask<bool> WaitToWriteAsync(CancellationToken cancellationToken = default)
    Возвращает ValueTask со значением true, который завершится, когда в канале появится место для записи. Значение false будет в том случае, если записи в канал более не будут разрешены;
  4. Виртуальный метод ValueTask WriteAsync(T item, CancellationToken cancellationToken = default)
    Асинхронно пишет в канал. Например, в случае, если канал заполнен, операция будет реально асинхронной и завершится только после освобождения места под данную запись;
  5. Метод void Complete(Exception? error = null)
    Просто пытается пометить канал как завершенный с помощью TryComplete, а в случае неудачи кидает исключение.

Небольшой пример вышеописанного (для легкого начала ваших собственных экспериментов):

Channel<int> unboundedChannel = Channel.CreateUnbounded<int>();//Объекты ниже можно отправить в разные потоки, которые будут использовать их независимо в своих целяхChannelWriter<int> writer = unboundedChannel;ChannelReader<int> reader = unboundedChannel;//Первый поток может писать в каналint objectToWriteInChannel = 555;await writer.WriteAsync(objectToWriteInChannel);//И завершить его, при исключении или в случае, когда записал все, что хотелwriter.Complete();//Второй может читать данные из канала по мере их доступностиint valueFromChannel = await reader.ReadAsync();

А теперь перейдем к самой интересной части.

Асинхронность без алллокаций


В процессе написания и изучения кода, я осознал, что почти ничего интересного в реализации всех этих операций нет. В общем можно описать так избегание лишних блокировок с помощью конкурентных коллекций и обильное использование ValueTask, который является структурой, что экономит память. Однако спешу напомнить, что не стоит быстрой заменой проходиться по всем файлам на вашей ПЭВМ и заменять все Task на ValueTask. Он имеет смысл только в случаях, когда операция в большинстве случаев завершается синхронно. Ведь, как мы помним, при асинхронности вполне вероятна смена потока, а значит и стек уже будет не тот, что прежде. Да и вообще, истинный профессионал в области производительности знает не оптимизируй до возникновения проблем.

Радует одно, в профессионалы я себя записывать не буду, а поэтому самое время разобраться, в чем же секрет написания асинхронного кода без выделений памяти, что на первый взгляд звучит слишком хорошо для правды. Но бывает и такое.

Интерфейс IValueTaskSource


Начнем наш путь с истоков структуры ValueTask, которая была добавлена в .net core 2.0 и дополнена в 2.1. Внутри этой структуры скрывается хитрое поле object _obj. Несложно догадаться, опираясь на говорящее название, что в этом поле может скрываться одна из 3 вещей null, Task/Task<T> или IValueTaskSource. На самом деле, это вытекает из способов создания ValueTask.

Как заверяет производитель, данную структуру следует использовать лишь очевидно с ключевым словом await. То есть не следует применять await много раз к одному и тому же ValueTask, использовать комбинаторы, добавлять несколько продолжений и тп. Также не следует получать результат из ValueTask более одного раза. А связано это как раз с тем, что мы пытаемся понять переиспользованием всего этого добра без выделения памяти.

Я уже упомянул интерфейс IValueTaskSource. Именно он помогает сэкономить память. Делается это с помощью переиспользования самого IValueTaskSource несколько раз для множества задач. Но именно из-за этого переиспользования и нет возможности баловаться с ValueTask.

Итак, IValueTaskSource. Данный интерфейс имеет 3 метода, реализовав которые вы будете успешно экономить память и время на выделении тех заветных байт.

  1. GetResult Вызывается единожды, когда в стейт машине, образованной на рантайме для асинхронных методов, понадобится результат. В ValueTask есть метод GetResult, который и вызывает одноименный метод интерфейса, который, как мы помним, может хранится в поле _obj.
  2. GetStatus Вызывается стейт машиной для определения состояния операции. Также через ValueTask.
  3. OnCompleted Опять же, вызывается стейт машиной для добавления продолжения к невыполненной на тот момент задаче.

Но несмотря на простой интерфейс, реализация потребует определенной сноровки. И тут можно вспомнить про то, с чего мы начали Channels. В данной реализации используется класс AsyncOperation, который является реализацией IValueTaskSource. Данный класс скрыт за модификатором доступа internal. Но это не мешает разобраться, в основных механизмах. Напрашивается вопрос, почему не дать реализацию IValueTaskSource в массы? Первая причина (хохмы ради) когда в руках молоток, повсюду гвозди, когда в руках реализация IValueTaskSource, повсюду неграмотная работа с памятью. Вторая причина (более правдоподобная) в то время, как интерфейс прост и универсален, реальная реализация оптимальна при использований определенных нюансов применения. И вероятно именно по этой причине можно найти реализации в самых разных частях великого и могучего .net, как то AsyncOperation под капотом каналов, AsyncIOOperation внутри нового API сокетов и тд.

CompareExchange


Довольно популярный метод популярного класса, позволяющий избежать накладных расходов на классические примитивы синхронизации. Думаю, большинство знакомы с ним, но все же стоит описать в 3 словах, ведь данная конструкция используется довольно часто в AsyncOperation.
В массовой литературе данную функцию называют compare and swap (CAS). В .net она доступна в классе Interlocked.

Сигнатура следующая:

public static T CompareExchange<T>(ref T location1, T value, T comparand) where T : class;

Имеются также перегрузи с int, long, float, double, IntPtr, object.

Сам метод атомарный, то бишь выполняется без прерываний. Сравнивает 2 значения и, если они равны, выполняет присваивание нового значения в переменную. Решают проблему, когда нужно проверить значение переменной и в зависимости от него изменить переменную.

Допустим, вы хотите инкрементировать переменную, если ее значение меньше 10.

Далее идут 2 потока.

Поток 1 Поток 2
Проверяет значение переменной на некоторое условие (то есть меньше ли оно 10), которое срабатывает -
Между проверкой и изменением значения Присваивает переменной значение, не удовлетворяющее условию (например, 15)
Изменяет значение, хотя не должен, ведь условие уже не соблюдается -


При использовании данного метода, вы либо изменяете именно то значение, что хотели, либо не изменяете, получив при этом актуальное значение переменной.

location1 переменная, значение которой мы хотим поменять. Оно сравнивается с comparand, в случае равенства в location1 записывается value. Если операция удалась, то метод вернет прошлое значение переменной location1. Если же нет, то будет возращено актуальное значение location1.
Если говорить чуть глубже, то существует инструкция языка ассемблера cmpxchg, которая выполняет эти действия. Именно она и используется под капотом.

Stack dive


Рассматривая весь этот код я не раз наткнулся на упоминания Stack Dive. Это очень крутая и интересная штука, которая на самом деле очень нежелательна. Суть в том, что при синхронном выполнении продолжений мы можем исчерпать ресурсы стека.

Допустим, мы имеем 10000 задач, в стиле

//code1await ...//code2

Допустим, первая задача завершает выполнение и этим освобождает продолжение второй, которое мы начинаем тут же выполнять синхронно в этом потоке, то есть забирая кусок стека стек фреймом данного продолжения. В свою очередь, данное продолжение разблокирует продолжение третей задачи, которое мы тоже начинаем сразу выполнять. И так далее. Если в продолжении больше нет await'ов или чего-то, что как-то сбросит стек, то мы просто будем потреблять стековое пространство до упора. Что может вызвать StackOverflow и крах приложения. В рассмотрении кода я упомяну, как с этим борется AsyncOperation.

AsyncOperation как реализация IValueTaskSource


Source code.

Внутри AsyncOperation есть поле _continuation типа Action<object>. Поле используется для, не поверите, продолжений. Но, как это часто бывает в слишком современном коде, у полей появляются дополнительные обязанности (как сборщик мусора и последний бит в ссылке на таблицу методов). Поле _continuation из той же серии. Есть 2 специальных значения, которые могут хранится в этом поле, кроме самого продолжения и null. s_availableSentinel и s_completedSentinel. Данные поля показывают, что операция доступна и завершена соответственно. Доступна она бывает как раз для переиспользования для совершенно асинхронной операции.

Также AsyncOperation реализует IThreadPoolWorkItem с единственным методом void Execute() => SetCompletionAndInvokeContinuation(). Метод SetCompletionAndInvokeContinuation как раз и занимается выполнением продолжения. И данный метод вызывается либо напрямую в коде AsyncOperation, либо через упомянутый Execute. Ведь типы реализующие IThreadPoolWorkItem можно забрасывать в тред пул как-то вот так ThreadPool.UnsafeQueueUserWorkItem(this, preferLocal: false).

Метод Execute будет выполнен тред пулом.

Само выполнение продолжения довольно тривиально.

Продолжение _continuation копируется в локальную переменную, на ее место записывается s_completedSentinel искусственный объект-марионетка (иль часовой, не знаю, как глаголить мне в нашей речи), который указывает, что задача завершена. Ну а далее локальная копия реального продолжения просто выполняется. При наличии ExecutionContext, данные действия постятся в контекст. Никакого секрета тут нет. Этот код может быть вызван как напрямую классом просто вызвав метод, инкапсулирующий эти действия, так и через интерфейс IThreadPoolWorkItem в тред пуле. Теперь можно догадаться, как работает функция с выполнением продолжений синхронно.

Первый метод интерфейса IValueTaskSource GetResult (github).

Все просто, он:

  1. Инкрементирует _currentId.
    _currentId то, что идентифицирует конкретную операцию. После инкремента она уже не будет ассоциирована с этой операцией. Поэтому не следует получать результат дважды и тп;
  2. помещает в _continuation делегат-марионетку s_availableSentinel. Как было упомянуто, это показывает, что этот экземпляр AsyncOperation можно испоьзовать повторно и не выделять лишней памяти. Делается это не всегда, а лишь если это было разрешено в конструкторе (pooled = true);
  3. Возвращает поле _result.
    Поле _result просто устанавливается в методе TrySetResult который описан ниже.

Метод TrySetResult (github).

Метод тривиален. он сохраняет принятый параметр в _result и сигнализирует о завершении, а именно вызывает метод SignalCompleteion, который довольно интересен.

Метод SignalCompletion (github).

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

В самом начале, если _comtinuation == null, мы записываем марионетку s_completedSentinel.

Далее метод можно разделить на 4 блока. Сразу скажу для простоты понимания схемы, 4 блок просто синхронное выполнение продолжения. То есть тривиальное выполнение продолжения через метод, как я описано в абзаце про IThreadPoolWorkItem.

  1. Если _schedulingContext == null, т.е. нет захваченного контекста (это первый if).
    Далее необходимо проверить _runContinuationsAsynchronously == true, то есть явно указано, что продолжения нужно выполнять как все привыкли асинхронно (вложенный if).
    При соблюдении данный условий в бой идет схема с IThreadPoolWorkItem описанная выше. То есть AsyncOperation добавляется в очередь на выполнение потоком тред пула. И выходим из метода.
    Следует обратить внимание, что если первый if прошел (что будет очень часто, особенно в коре), а второй нет, то мы не попадем в 2 или 3 блок, а спустимся сразу на синхронное выполнение продолжения т.е. 4 блок;
  2. Если _schedulingContext is SynchronizationContext, то есть захвачен контекст синхронизации (это первый if).
    По аналогии мы проверяем _runContinuationsAsynchronously = true. Но этого не достаточно. Необходимо еще проверить, контекст потока, на котором мы сейчас находимся. Если он отличен от захваченного, то мы тоже не можем просто выполнить продолжение. Поэтому если одно из этих 2 условий выполнено, мы отправляем продолжение в контекст знакомым способом:
    sc.Post(s => ((AsyncOperation<TResult>)s).SetCompletionAndInvokeContinuation(), this);
    

    И выходим из метода. опять же, если первая проверка прошла, а остальные нет (то есть мы сейчас находимся на том же контексте, что и был захвачен), мы попадем сразу на 4 блок синхронное выполнение продолжения;
  3. Выполняется, если мы не зашли в первые 2 блока. Но стоит расшифровать это условие.
    Хитрость в том, что _schedulingContext может быть на самом деле захваченным TaskScheduler, а не непосредственно контекстом. В этом случае мы поступаем также, как и в блоке 2, т.е. проверяем флаг _runContinuationsAsynchronously = true и TaskScheduler текущего потока. Если планировщик не совпадает или флаг не тот, то мы сетапим продолжение через Task.Factory.StartNew и передаем туда этот планировщик. И выходим из метода.
  4. Как и сказал в начале просто выполняем продолжение на текущем потоке. Раз мы до сюда дошли, то все условия для этого соблюдены.

Второй метод интерфейса IValueTaskSource GetStatus (github)
Просто как питерская пышка.

Если _continuation != _completedSentinel, то возвращаем ValueTaskSourceStatus.Pending
Если error == null, то возвращаем ValueTaskSourceStatus.Succeeded
Если _error.SourceException is OperationCanceledException, то возвращаем ValueTaskSourceStatus.Canceled
Ну а коль уж до сюда дошли, то возвращаем ValueTaskSourceStatus.Faulted

Третий и последний, но самый сложный метод интерфейса IValueTaskSource OnCompleted (github)

Метод добавляет продолжение, которое выполняется по завершению.

При необходимости захватывает ExecutionContext и SynchronizationContext.

Далее используется Interlocked.CompareExchange, описанный выше, чтобы сохранить продолжение в поле, сравнивая его с null. Напоминаю, что CompareExchange возвращает актуальное значение переменной.

Если сохранение продолжения прошло, то возвращается значение, которое было в переменной до обновления, то есть null. Это означает, что операция еще не завершилась на момент записи продолжения. И тот, кто ее завершит сам со всем разберется (как мы смотрели выше). И нам нет смысла выполнять какие-то дополнительные действия. И на этом работа метода завершается.

Если сохранить значение не получилось, то есть из CompareExchange вернулось что-то кроме null. В этом случае кто-то успел положить значение в быстрее нас. То есть произошла одна из 2 ситуаций или задача завершилась быстрее, чем мы до сюда дошли, или была попытка записать более 1 продолжения, что делать нельзя.

Таким образом проверяем возвращенное значение, равно ли оно s_completedSentinel именно оно было бы записано в случае завершения.

  • Если это не s_completedSentinel, то нас использовали не по плану попытались добавить более одного продолжения. То есть то, которое уже записано, и то, которое пишем мы. А это исключительная ситуация;
  • Если это s_completedSentinel, то это один из допустимых исходов, операция уже завершена и продолжение должны вызвать мы, здесь и сейчас. И оно будет выполнено асинхронно в любом случае, даже если _runContinuationsAsynchronously = false.
    Сделано это так, потому что если мы дошли до этого места, значит мы внутри метода OnCompleted, внутри awaiter'а. А синхронное выполнение продолжений именно здесь грозит упомянутым стек дайвом. Сейчас вспомним, для чего нам нужна эта AsyncOperation System.Threading.Channels. А там ситуация может быть очень легко достигнута, если о ней не задуматься. Допустим, мы читатель в ограниченном канале. Мы читаем элемент и разблокируем писателя, выполняем его продолжение синхронно, что разблокирует очередного читателя(если читатель очень быстр или их несколько) и так далее. Тут стоит осознать тонкий момент, что именно внутри awaiter'а возможна эта ситуация, в других случаях продолжение выполнится и завершится, что освободит занятый стек фрейм. А постоянный зацеп новых продолжений вглубь стека порождается постоянным выполнением продолжения внутри awaiter'а.
    В целях избежания данной ситуации, несмотря ни на что необходимо запустить продолжение асинхронно. Выполняется по тем же схемам, что и первые 3 блока в методе SignalCompleteion просто в пуле, на контексте или через фабрику и планировщик

А вот и пример синхронных продолжений:

class Program    {        static async Task Main(string[] args)        {            Channel<int> unboundedChannel = Channel.CreateUnbounded<int>(new UnboundedChannelOptions            {                AllowSynchronousContinuations = true            });            ChannelWriter<int> writer = unboundedChannel;            ChannelReader<int> reader = unboundedChannel;            Console.WriteLine($"Main, before await. Thread id: {Thread.CurrentThread.ManagedThreadId}");            var writerTask = Task.Run(async () =>            {                Thread.Sleep(500);                int objectToWriteInChannel = 555;                Console.WriteLine($"Created thread for writing with delay, before await write. Thread id: {Thread.CurrentThread.ManagedThreadId}");                await writer.WriteAsync(objectToWriteInChannel);                Console.WriteLine($"Created thread for writing with delay, after await write. Thread id: {Thread.CurrentThread.ManagedThreadId}");            });            //Blocked here because there are no items in channel            int valueFromChannel = await reader.ReadAsync();            Console.WriteLine($"Main, after await (will be processed by created thread for writing). Thread id: {Thread.CurrentThread.ManagedThreadId}");            await writerTask;            Console.Read();        }    }

Output:

Main, before await. Thread id: 1
Created thread for writing with delay, before await write. Thread id: 4
Main, after await (will be processed by created thread for writing). Thread id: 4
Created thread for writing with delay, after await write. Thread id: 4
Подробнее..

Почему медленная загрузка сайта убивает SEO. И как шиншиллам не остаться без домиков

22.06.2020 20:08:04 | Автор: admin
Медленные страницы не просто тормозят, а сводят поисковую оптимизацию на нет. Можно сколько угодно вкладываться в контент и расшаривание сайта, но продолжать терять позиции из-за большого количества отказов.


Медленные сайты раздражают не только людей, но и поисковых роботов. Google и Яндекс старательно избавляются от страниц, которые грузятся дольше положенного. Таким отводится место в нижней части выдачи чтобы не портили настроение пользователям. Мало кто покупает домики для шиншилл на сайте, открывающемся дольше 5 секунд.

Мы уже поднимали вопрос медленных страниц. В статье Зачем и как проверять скорость загрузки сайта объясняется, как быстродействие влияет на SEO и на конверсию продаж. Суть в том, что поисковые системы оценивают сайты в первую очередь по поведению пользователей. А поскольку люди негативно реагируют на медленную загрузку, такие ресурсы не имеют шансов на продвижение.

Давайте посмотрим, как это работает. Для примера возьмем условный сайт, продающий домики для шиншилл.

Как работает SEO


SEO расшифровывается как search engine optimization, что в переводе означает поисковая оптимизация. Это комплекс работ по усовершенствованию сайта в соответствии с требованиями поисковых систем. SEO-оптимизированный ресурс поднимается выше в поиске, чаще показывается людям, на нем больше посетителей, он лучше продает.

Нет той волшебной кнопки, которая бы автоматически запускала сайт в ТОП-3 выдачи слишком сложны поисковые алгоритмы. Вот почему SEO это многоплановый и протяженный во времени процесс, который не останавливается ни на месяц.

Внешние и внутренние факторы SEO


Факторы, по которым оцениваются сайты, делятся на внутренние и внешние.

Внутренние факторы держатся на трех китах:

  1. Релевантность насколько каждая страница сайта соответствует запросу пользователя.
  2. Качество содержимого дизайн, информативность.
  3. Юзабилити удобство сайта.

Основной внешний фактор авторитетность ресурса, то есть его цитируемость в интернете. Чем больше ссылок и переходов со сторонних сайтов, тем лучше.

Сайт должен на 100% решать задачу пользователя в максимально удобной для него форме.

Обратимся к домикам для шиншилл. Чтобы сделать сайт релевантным, нужно заточить его под коммерческие запросы: купить домик для шиншиллы, продажа домиков для шиншилл и т.п.

На страницах ресурса размещается информативный контент, четко отвечающий на вопросы пользователей: что за домики, сколько стоят, как купить. Привлекательный дизайн создает нужный эмоциональный фон и вызывает доверие.

Не менее важно позаботиться о юзабилити в частности, о быстродействии сайта. Если страница не откроется вовремя, пользователь уйдет. И многие шиншиллы останутся без домиков.

Почему так важны поведенческие факторы


Отношение людей к сайту, поведение на нем важнейшая информация для поисковиков. Яндекс и Google внимательно анализируют то, как ведут себя интернет-пользователи. Учитывается всё:

  • откуда пришли посетители;
  • сколько времени провели;
  • на какие страницы переходили;
  • как быстро уходили;
  • как часто возвращались к сайту.

Каждый переход, каждый клик плюс в копилку ранжирования. А каждый отказ жирный минус.


Google Аналитика, поведение пользователей

Показатель отказов


Один из ключевых параметров показатель отказов. Поисковый робот учитывает, сколько людей ушли с сайта, посмотрев всего одну страницу. Для системы это означает, что ресурс нерелевантный, некачественный или неудобный. Его рейтинг автоматически понижается.

Одна из причин большого процента отказов медленная загрузка страниц. С трудом открывающийся сайт подрывает доверие к себе. Люди делают вывод, что здесь не очень-то заботятся о них и незачем тратить свое время.

Скорость сайта как часть юзабилити


Прежде чем задержаться на сайте, человек должен на него попасть. 47% пользователей утверждают, что закроют страницу, если она грузится дольше 2 секунд. 88% говорят, что вряд ли вернутся на сайт после неудачной попытки его открыть.

По статистике, среднее время загрузки коммерческого сайта 3,5 секунды.

Многие современные ресурсы грузятся до 5 секунд. И задержка свыше этой цифры недопустима. Подавляющее большинство пользователей не готовы ждать так долго. Тем более, что в их распоряжении целая поисковая выдача. А в ней немало других продавцов, более внимательных к своим клиентам.

Скорость загрузки страниц едва ли не главная составляющая юзабилити. Согласитесь, можно заказать супер-красивый дизайн и быть экспертом по разведению шиншилл. Но, пока сайт не станет быстрым и доступным, SEO будет буксовать.

Поисковый интерес


В Google убедились: люди реже пользуются их поиском, если в выдаче медленные сайты. Это заставило компанию скорректировать алгоритм ранжирования с учетом скорости загрузки ресурсов.

Поисковым системам выгодно расширять свою аудиторию, показывать больше рекламы и увеличивать доход. Кроме того, поисковики заботятся о своих пользователях, рассчитывая на их лояльность. Поэтому за медленную загрузку накладываются санкции. Преодолеть ситуацию можно только ускорением страниц.

Сайт должен быстро загружаться как на компьютере, так и на мобильных устройствах (они обеспечивают не менее 50% трафика).

Как медленные страницы убивают сайт


Можно долго и упорно оптимизировать контент, чтобы добиться безупречной релевантности. Или расшаривать сайт в Сети, распространяя ссылки с приглашением посетить ресурс. Но, даже вложившись в рекламу, получить отказы.

Большое количество отказов неизбежно приведет к санкциям. SEO не только не принесет результата, но напротив позиции сайта могут быть понижены.



Сайт попал под фильтр, падение трафика

Пример неудачного расшаривания


Продавец домиков создал в соцсети сообщество для заводчиков шиншилл и в группе предусмотрительно разместил ссылку на сайт.

Посетитель группы решил перейти по ссылке, чтобы ознакомиться с продукцией. Заметим, что это требует от пользователя усилий: он вынужден покидать зону комфорта своей соцсети и тратить время.

Соцсеть тоже не заинтересована терять посетителя, и процесс перехода затормаживается. Велика вероятность, что пользователь закроет вкладку, поэтому сайт должен загружаться как можно быстрее. Но он открывается со скрипом, и часть людей отвечают отказом. На что обязательно обращает внимание поисковый робот.

Быстрый или полезный что важнее?


По мере развития, сайты становятся тяжелее обрастают контентом и функционалом. Помимо товаров с ценами, в интернет-магазине появляются статьи, фото и видео о шиншиллах. На каждой странице вылезает настырный чат, приглашающий к диалогу с менеджером. А экран заслоняет заставка с акцией.

Возникает вопрос: что важнее быстродействие или качество? Этот компромисс решается техническими средствами.

Безусловно, нельзя забывать о содержании ресурса. Поэтому SEO-специалисты постоянно совершенствуют контентную и функциональную части сайта. Но эти доработки не должны пагубно влиять на скорость. Иначе сайт получит отказы и просядет в поиске.

Почему в ТОПе есть медленные сайты?


На первых страницах поиска медленные сайты не такая уж и редкость. Ведь быстродействие лишь один из факторов ранжирования. Значимый, но далеко не главный.

В ТОП выдачи, как правило, попадают лидеры рынка, в том числе интернет-гипермаркеты и авторитетные порталы. Такие сайты часто перегружены контентом и функционалом. Но люди, по ряду причин, готовы прощать им неповоротливость. Например:

  • у компании большой авторитет;
  • бизнес имеет широчайшую аудиторию;
  • предлагается уникальная услуга и т.д.

Когда люди настроены лояльно, поисковые роботы тоже закрывают глаза на технические огрехи и для многих значимых федеральных компаний делают исключения.

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

Ускорение как метод SEO


Повышение скорости сайта один из хрестоматийных методов поисковой оптимизации. Планируя комплекс мероприятий по оптимизации, SEO-специалист обязательно анализирует быстродействие. Для этого используются инструменты веб-аналитики и специализированные сервисы.


Улучшение показателя page speed

Если скорость загрузки сайта или отдельных страниц страдает, выясняются причины и предпринимаются меры по ускорению работы. Результаты отслеживают и представляют владельцу сайта в виде отчетов. На тех же графиках аналитики хорошо видно, как улучшаются поведенческие факторы и позиции в поиске.

Больше 50% российских сайтов нуждаются в ускорении.

Ускорение не отменяет других методов SEO. А улучшение показателя page speed не должно производиться в ущерб релевантности и качеству содержимого. Кому нужен пустой примитивный сайт, даже если он открывается мгновенно?

Мониторинг и аудит


Медленные страницы убивают и SEO, и сам сайт. В ресурс с малой посещаемостью и плохими продажами не хочется вкладываться.

Это напоминает замкнутый круг:

  • Люди отказываются переходить на сайт.
  • Поисковики накладывают санкции.
  • Реклама и расшаривание не срабатывают.

Поэтому важно отслеживать скорость загрузки страниц и повышать ее. Чем быстрее работает сайт, тем меньше отказов и эффективнее SEO. А довольные шиншиллы спокойно спят в своих домиках.
Подробнее..

Язык программирования Mash

25.06.2020 02:19:01 | Автор: admin

http://mash-project.org
https://github.com/RoPi0n/mash-lang

Mash?


Это язык императивный язык программирования с динамической типизацией, сборкой мусора, ООП и поддержкой многопоточности.

Интересно? Тогда под кат! :)


Насколько завершен проект?



На данный момент язык находится на Pre-Release этапе, его функционала и производительности пока что не достаточно, чтобы решать с его помощью абсолютно любые задачи, но именно к этому я и стремлюсь.
В репозитории выложена сборка под Win64, в которой весь функционал языка отлажен и работает без багов.

По функционалу Mash схож с Python.
Язык спроектирован максимально простым и полнофункциональным.
Полностью поддерживает ООП, интроспекцию, рефлексию, функции высшего порядка, многопоточность, синхронизацию, распараллеливание и синхронизацию кода внутри тела методов.

В качестве среды выполнения языка используется стековая виртуальная машина (ВМ), поддерживающая многопоточность, сборку мусора (Reference Counting).
Реализован транслятор языка (с построением AST, все как по книжкам) в абстрактный код для ВМ и в перспективе в другие языки (но из-за сложности языка это пока только планы на будущее).
Для удобства в ознакомлении с языком и работе с ним реализована небольшая IDE (FPC + SynEdit).

Встраиваемость


ВМ языка имеет API, функционал языка можно расширять путем написания библиотек для ВМ на любом нативном языке (FPC, Delphi, C/C++, Rust и т.д.),
также язык можно встроить в любой ваш проект, при этом получить в свое распоряжение весь функционал Mash'a и его нативных библиотек.

Debug?


У ВМ предусмотрена возможность работы в связке с отладчиком (который пока что очень сырой).
Язык поддерживает трассировку исключений.


Поддержка платформ


Язык полностью написан на Free Pascal, который в свою очередь поддерживает огромный список платформ, под которые может быть собран Mash.
Зависимости от каких-либо библиотек отсутствуют.

Проект уже собирал ранее и тестировал на Windows, Linux и Android.

На что Mash способен уже сейчас?


На данный момент я работаю над реализацией стандартного набора библиотек для работы с файлами, математикой, I/O, многопоточностью, GUI, сетью, базами данных, криптографией и т.д.


В репозитории проекта (и в Pre-Release сборке) можно найти небольшие демо-приложения.
На Mash написана змейка, асинхронный веб сервер/фреймворк (по типу Flask'a), отрисовка графиков в декартовой и полярных системах координат, Аттрактор Лоренца, вращение простых 3D моделек по типу кубика, а так же версия транслятора Mash'a, написанная на Mash'e.

Что дальше?


Над проектом работаю пока один в рамках хобби, так что его будущее все ещё остается не определенным (+вуз, +разные неопределенные сложности и релиз может отложиться на долго).
В данной статье представлен промежуточный результат работы, все ещё далекий от конечной цели.
В разработке, помимо библиотек для ВМ, находится JIT компилятор по типу HotSpot.

Дочитали до конца?


Спасибо за внимание :)
Подробнее..

Обновление списка TOP500 впервые лидером стал суперкомпьютер на процессорах ARM

23.06.2020 20:12:31 | Автор: admin
Опубликована 55 редакция рейтинга самых высокопроизводительных суперкомпьютеров мира.
О новых лидерах списка и возможностях суперкомпьютеров экстра-класса читайте под катом.



Предыдущий лидер списка суперкомпьютер Summit (OLCF-4) Ок-Риджской национальной лаборатории стал вторым, уступив почетное первое место новой японской топ-системе Fugaku, которая показала результат High Performance Linpack (HPL) равный 415,5 петафлопс. Данный показатель превосходит возможности Summit в 2,8 раза. Fugaku оснащен 48-ядерным процессором A64FX SoC от Fujitsu, таким образом, японская разработка стала первой в истории системой 1 в списке ТOP500, оснащенной процессорами ARM. При одинарной или более низкой точности, которая часто используется для задач машинного обучения и искусственного интеллекта, пиковая производительность Fugaku составляет более 1000 петафлопс (1 экзафлопс). Новая система установлена в Центре вычислительных наук RIKEN (R-CCS) в Кобе, Япония.

Упомянутый выше Summit, суперкомпьютер, созданный IBM, показывает в тесте HPL производительность в 148,8 петафлопс. Система имеет 4356 узлов, каждый из которых оснащен двумя 22-ядерными процессорами Power9 и шестью графическими ускорителями NVIDIA Tesla V100. Узлы объединяет сеть InfiniBand EDR. Summit остается самым быстрым суперкомпьютером в США.

На третьем месте тоже оказался американец суперкомьютер Sierra Ливерморской национальной лаборатории им. Лоуренса (LLNL), Калифорния, показавший результат в 94,6 петафлопс. Его архитектура очень похожа на Summit: он оснащен двумя процессорами Power9 и четырьмя графическими ускорителями NVIDIA Tesla V100 в каждом из 4320 узлов. Sierra использует тот же InfiniBand Mellanox EDR, что и Sunway TaihuLight, суперкомпьютер, разработанный Китайским национальным исследовательским центром параллельной вычислительной техники и технологий (NRCPC). Он, к слову, опустился на четвертое место в списке. Система полностью основана на 260-ядерных процессорах Sunway SW26010. Его отметка HPL в 93 петафлопс осталась неизменной с момента его установки в Национальном суперкомпьютерном центре в Уси, Китай, в июне 2016 года.

На пятом месте также находится китайская разработка Tianhe-2A (Milky Way-2A), реализованная Китайским национальным университетом оборонных технологий (NUDT). Его производительность HPL 61,4 петафлопс является результатом гибридной архитектуры с использованием процессоров Intel Xeon и специально созданных сопроцессоров Matrix-2000. Он развернут в Национальном суперкомпьютерном центре в Гуанчжоу, Китай.

Новичок в списке, HPC5, занял шестое место, показав производительность HPL 35,5 петафлопс. HPC5 это система PowerEdge, созданная Dell и реализованная итальянской энергетической фирмой Eni S.p.A, что делает ее самым быстрым суперкомпьютером в Европе.

Еще одна новая система, Selene, находится на седьмом месте с показателем HPL 27,58 петафлопс. Selene установлена на NVIDIA в США.

Frontera, система Dell C6420, установленная в Техасском вычислительном центре (TACC) в США, занимает восьмое место в списке. Его 23,5 HPL петафлопс достигается с помощью 448,448 ядер Intel Xeon.

Второй итальянский суперкомпьютер в топ-10 Marconi-100, он установлен в исследовательском центре CINECA. Marconi-100 работает на процессорах IBM Power9 и графических ускорителях NVIDIA V100, его производительность равна 21,6 петафлопс, он занял девятое место в списке.
Завершает топ-10 с показателем 19,6 петафлопс система Cray XC50, установленная в Швейцарском национальном суперкомпьютерном центре (CSCS) в Лугано. Он оснащен процессорами Intel Xeon и графическими ускорителями NVIDIA P100.

Российская разработка суперкомпьютер Кристофари (Christofari) на базе Xeon Platinum, Nvidia DGX-2 и Tesla V100 набирает в тесте HPL 6,67 петафлопс, занимая пока лишь 36 место.





Результаты Green500


Самая энергоэффективная система в списке Green500 это MN-3, основанная на новом сервере от Preferred Networks. Суперкомпьютер достиг рекордного показателя в 21,1 гигафлопс /ватт при производительности 1,62 петафлопс. Система обладает превосходной энергоэффективностью благодаря чипу MN-Core, ускорителю, оптимизированному для матричной арифметики. Занимает 395 место в списке TOP500.

На втором месте новый суперкомпьютер NVIDIA Selene, DGX A100 SuperPOD, работающий на новых графических ускорителях A100. На третьем месте находится система NA-1, система PEZY Computing / Exascaler, установленная в NA Simulation в Японии. Суперкомпьютер достиг 18,4 гигафлопс / ватт и находится на позиции 470 в TOP500.

Основные тренды


  • Совокупная производительность списка теперь составляет 2,23 экзафлопс, по сравнению с показателем в 1,65 экзафлопс всего шесть месяцев назад. Львиная доля такого бурного роста является заслугой нового суперкомпьютера Fugaku, занявшего 1е место в списке.
  • Общее количество новых систем в списке составляет всего 51, что является антирекордом с самого начала создания списка TOP500 (с 1993 года).
  • Китай продолжает доминировать в TOP500 по количеству систем (226), США по количеству суперкомпьютеров в списке занимает втрое место (114), Япония- третье (30).;
  • В общей сложности 144 системы из списка используют ускорители или сопроцессоры. Как и раньше, большинство систем, используют графические ускорители NVIDIA.
  • X86 продолжает оставаться доминирующей архитектурой процессора, присутствуя в 481 из 500 систем. Intel используется на 469 из них, AMD установлен в 11, Hygon в оставшихся.
  • Китайские производители доминируют в списке: на Lenovo (180), Sugon (68) и Inspur (64) приходится 312 из 500 систем.
Подробнее..

Перевод Смотрим на Chapel, D, Julia на задаче вычисления ядра матрицы

24.06.2020 14:16:49 | Автор: admin

Введение


Кажется, стоит вам отвернуться, и появляется новый язык программирования, нацеленный на решение некоторого специфического набора задач. Увеличение количества языков программирования и данных глубоко взаимосвязано, и растущий спрос на вычисления в области Data Science является связанным феноменом. В области научных вычислений языки программирования Chapel, D и Julia являются весьма релевантными. Они возникли в связи с различными потребностями и ориентированы на различные группы проблем: Chapel фокусируется на параллелизме данных на отдельных многоядерных машинах и больших кластерах; D изначально разрабатывался как более продуктивная и безопасная альтернатива C++; Julia разрабатывалась для технических и научных вычислений и была нацелена на освоение преимуществ обоих миров высокой производительности и безопасности статических языков программирования и гибкости динамических языков программирования. Тем не менее, все они подчеркивают производительность как отличительную особенность. В этой статье мы рассмотрим, как различается их производительность при вычислении ядра матрицы, и представим подходы к оптимизации производительности и другие особенности языков, связанные с удобством использования.

Вычисление ядра матрицы формирует основу методов в приложениях машинного обучения. Задача достаточно плохо масштабируется -O(m n^2), где n количество векторов, а m количество элементов в каждом векторе. В наших упражнениях m будет постоянным и мы будем смотреть на время выполнения в каждой реализации по мере увеличения n. Здесь m = 784 и n = 1k, 5k, 10k, 20k, 30k, каждое вычисление выполняется три раза и берется среднее значение. Мы запрещаем любое использование BLAS и допускаем использование только пакетов или модулей из стандартной библиотеки каждого языка, хотя в случае D эталон еще сравнивается с вычислениями, использующими Mir, библиотеку для работы с многомерными массивами, чтобы убедиться, что моя реализация матрицы отражает истинную производительность D. Подробности вычисления ядра матрицы и основных функций приведены здесь.

При подготовке кода для этой статьи сообщества Chapel, D и Julia были очень полезны и терпеливы в отношении всех моих обращений, чему я признателен.

С точки зрения предвзятости, в начале работы, я был гораздо лучше знаком с D и Julia, чем с Chapel. Тем не менее, для получения наилучшей производительности от каждого языка требовалось взаимодействие с сообществами каждого языка, и я делал всё возможное, чтобы осознавать мои предубеждения и исправлять их там, где это было необходимо.

Бенчмарки языков программирования на задаче вычисления ядра матрицы


Приведенная выше диаграмма (сгенерированная с помощью ggplot2 на R с помощью скрипта) показывает время выполнения для количества элементов n для Chapel, D, и Julia, для девяти вычислений ядра. D лучше всего работает в пяти из девяти случаев, Julia лучше в двух из девяти, а в двух задачах (Dot и Gaussian) картинка смешанная. Chapel был самым медленным для всех рассмотренных задач.
Стоит отметить, что математические функции, используемые в D, были взяты из math API языка C, доступного в D через core.stdc.math, так как математические функции в стандартной библиотеке std.math языка D бывают достаточно медленными. Использованные математические функции приведены здесь. Для сравнения рассмотрим скрипт mathdemo.d, сравнивающий C-функцию логарифма с D-функцией из std.math:
$ ldc2 -O --boundscheck=off --ffast-math --mcpu=native --boundscheck=off mathdemo.d && ./mathdemoTime taken for c log: 0.324789 seconds.Time taken for d log: 2.30737 seconds.

Объект Matrix, используемый в бенчмарке D, был реализован специально из-за запрета на использование модулей вне стандартных языковых библиотек. Чтобы удостовериться, что эта реализация конкурентоспособна, т.е. не представляет собой плохую реализацию на D, я ее сравниваю с библиотекой Mir's ndslice, тоже написанной на D. На диаграмме ниже показано время вычисления матрицы минус время реализации ndslice; отрицательное значение означает, что ndslice работает медленнее, что указывает на то, что используемая здесь реализация не представляет собой негативную оценку производительности D.


Условия тестирования


Код был выполнен на компьютере с операционной системой Ubuntu 20.04, 32 ГБ памяти и процессором Intel Core i9-8950HK @ 2.90GHz с 6-ю ядрами и 12-ю потоками.
$ julia --versionjulia version 1.4.1$ dmd --versionDMD64 D Compiler v2.090.1$ ldc2 --versionLDC - the LLVM D compiler (1.18.0):  based on DMD v2.088.1 and LLVM 9.0.0$ chpl --versionchpl version 1.22.0

Компиляция


Chapel:
chpl script.chpl kernelmatrix.chpl --fast && ./script

D:
ldc2 script.d kernelmatrix.d arrays.d -O5 --boundscheck=off --ffast-math -mcpu=native && ./script

Julia (компиляция не требуется, но может быть запущена из командной строки):
julia script.jl


Реализации


При реализации этих основных функций были предприняты усилия, чтобы избежать использования нестандартных библиотек. Причины этого:
  • Чтобы читатель после установки языковой среды мог легко скопировать и запустить код. Необходимость установки внешних библиотек может быть немного неудачной.
  • Пакеты, не относящиеся к стандартным библиотекам, могут исчезнуть, поэтому отказ от внешних библиотек сохраняет актуальность статьи и кода.
  • Статья полностью прозрачна и показывает, как работает каждый из языков.

Chapel


Chapel использует цикл forall для распараллеливания по потокам. Также используется C-указатели на каждый элемент, а не стандартное обращение к массивам, и применяется guided итерация по индексам:
proc calculateKernelMatrix(K, data: [?D] ?T){  var n = D.dim(0).last;  var p = D.dim(1).last;  var E: domain(2) = {D.dim(0), D.dim(0)};  var mat: [E] T;  var rowPointers: [1..n] c_ptr(T) =    forall i in 1..n do c_ptrTo(data[i, 1]);  forall j in guided(1..n by -1) {    for i in j..n {      mat[i, j] = K.kernel(rowPointers[i], rowPointers[j], p);      mat[j, i] = mat[i, j];    }  }  return mat;}
Код на Chapel был самым трудным для оптимизации по производительности и потребовал наибольшего количества изменений кода.

D


Для распараллеливания кода D используется taskPool потоков из пакета std.parallel. Код на D претерпел наименьшее количество изменений для оптимизации производительности большая польза от использования специфического компилятора и выбранных ключей компиляции (обсуждается далее). Моя реализация Matrix позволяет отобрать столбцы по ссылке с помощью refColumnSelect.
auto calculateKernelMatrix(alias K, T)(K!(T) kernel, Matrix!(T) data){  long n = data.ncol;  auto mat = Matrix!(T)(n, n);  foreach(j; taskPool.parallel(iota(n)))  {    auto arrj = data.refColumnSelect(j).array;    foreach(long i; j..n)    {      mat[i, j] = kernel(data.refColumnSelect(i).array, arrj);      mat[j, i] = mat[i, j];    }  }  return mat;}

Julia


Код Julia использует макрос threads для распараллеливания кода и макрос views для ссылок на массивы. Единственное, что сбивает с толку с массивами в Julia это их ссылочный статус. Иногда, как и в этом случае, массивы будут вести себя как объекты-значения, и на них нужно ссылаться с помощью макроса views, иначе они будут генерировать копии. В других случаях они ведут себя как ссылочные объекты, например, при передаче их в функцию. С этим может быть немного сложно разобраться, потому что вы не всегда знаете, какой набор операций сгенерирует копию, но там, где это происходит, views обеспечивает хорошее решение.
Тип Symmetric позволяет сэкономить немного дополнительной работы, необходимой для отражения в матрице.
function calculateKernelMatrix(Kernel::K, data::Array{T}) where {K <: AbstractKernel,T <: AbstractFloat}  n = size(data)[2]  mat = zeros(T, n, n)  @threads for j in 1:n      @views for i in j:n          mat[i,j] = kernel(Kernel, data[:, i], data[:, j])      end  end  return Symmetric(mat, :L)end
Макросы @ bounds и @ simd в основных функциях использовались для отключения проверки границ и применения оптимизации SIMD к вычислениям:
struct DotProduct <: AbstractKernel end@inline function kernel(K::DotProduct, x::AbstractArray{T, N}, y::AbstractArray{T, N}) where {T,N}  ret = zero(T)  m = length(x)  @inbounds @simd for k in 1:m      ret += x[k] * y[k]  end  return retend
Эти оптимизации достаточно заметны, но очень просты в применении.

Использование памяти


Суммарное время для каждого бенчмарка и общая используемая память была собрана с помощью команды /usr/bin/time -v. Вывод для каждого из языков приведен ниже.

Chapel занял наибольшее общее время, но использовал наименьший объем памяти (почти 6 Гб оперативной памяти):
Command being timed: "./script"User time (seconds): 113190.32System time (seconds): 6.57Percent of CPU this job got: 1196%Elapsed (wall clock) time (h:mm:ss or m:ss): 2:37:39Average shared text size (kbytes): 0Average unshared data size (kbytes): 0Average stack size (kbytes): 0Average total size (kbytes): 0Maximum resident set size (kbytes): 5761116Average resident set size (kbytes): 0Major (requiring I/O) page faults: 0Minor (reclaiming a frame) page faults: 1439306Voluntary context switches: 653Involuntary context switches: 1374820Swaps: 0File system inputs: 0File system outputs: 8Socket messages sent: 0Socket messages received: 0Signals delivered: 0Page size (bytes): 4096Exit status: 0

D расходует наибольший объем памяти (около 20 ГБ оперативной памяти на пике), но занимает меньше общего времени, чем Chapel для выполнения:
Command being timed: "./script"User time (seconds): 106065.71System time (seconds): 58.56Percent of CPU this job got: 1191%Elapsed (wall clock) time (h:mm:ss or m:ss): 2:28:29Average shared text size (kbytes): 0Average unshared data size (kbytes): 0Average stack size (kbytes): 0Average total size (kbytes): 0Maximum resident set size (kbytes): 20578840Average resident set size (kbytes): 0Major (requiring I/O) page faults: 0Minor (reclaiming a frame) page faults: 18249033Voluntary context switches: 3833Involuntary context switches: 1782832Swaps: 0File system inputs: 0File system outputs: 8Socket messages sent: 0Socket messages received: 0Signals delivered: 0Page size (bytes): 4096Exit status: 0

Julia потратила умеренный объем памяти (около 7,5 Гб пиковой памяти), но выполнялась быстрее всех, вероятно, потому что ее генератор случайных чисел является самым быстрым:
Command being timed: "julia script.jl"User time (seconds): 49794.85System time (seconds): 30.58Percent of CPU this job got: 726%Elapsed (wall clock) time (h:mm:ss or m:ss): 1:54:18Average shared text size (kbytes): 0Average unshared data size (kbytes): 0Average stack size (kbytes): 0Average total size (kbytes): 0Maximum resident set size (kbytes): 7496184Average resident set size (kbytes): 0Major (requiring I/O) page faults: 794Minor (reclaiming a frame) page faults: 38019472Voluntary context switches: 2629Involuntary context switches: 523063Swaps: 0File system inputs: 368360File system outputs: 8Socket messages sent: 0Socket messages received: 0Signals delivered: 0Page size (bytes): 4096Exit status: 0

Оптимизация производительности


Процесс оптимизации производительности на всех трех языках был очень разным, и все три сообщества были очень полезны в этом процессе. Но были и общие моменты.
  • Статическая диспетчеризация функций ядра вместо использования полиморфизма. Это означает, что при передаче функции ядра используется параметрический (времени компиляции) полиморфизм, а не динамический (времени исполнения), при котором диспетчеризация с виртуальными функциями влечет за собой накладные расходы.
  • Использование представлений/ссылок, вместо копирования данных в многопоточном режиме, имеет большое значение.
  • Распараллеливание вычислений имеет огромное значение.
  • Знание того, что массив является основным для строки/столбца, и использование этого в вычислениях имеет огромное значение.
  • Проверки границ и оптимизации компилятора дают огромную разницу, особенно в Chapel и D.
  • Включение SIMD в D и Julia внесло свой вклад в производительность. В D это было сделано с помощью флага -mcpu=native, а в Julia это было сделано с помощью макроса @ simd.

С точки зрения специфики языка, наиболее сложным был переход к производительному коду в Chapel, и код в Chapel больше всего изменился: от простых для чтения операций с массивами до использования указателей и управляемых итераций. Но со стороны компилятора было относительно легко добавить --fast и получить большой прирост производительности.

Код на D изменился очень мало, и большая часть производительности была получена за счет выбора компилятора и его флагов оптимизации. Компилятор LDC богат возможностями оптимизации производительности. Он имеет 8 -O уровней оптимизации, но некоторые из них повторяются. Например, -O, -O3 и -O5 идентичны, а других флагов, влияющих на производительность, бесчисленное множество. В данном случае использовались флаги -O5 --boundscheck=off -ffast-math, представляющие собой агрессивные оптимизации компилятора, проверку границ, и LLVM's fast-math, и -mcpu=native для включения инструкций векторизации.

В Julia макросы в рассмотренных ранее изменениях заметно улучшили производительность, но они не были слишком запутанными. Я попробовал изменить уровень оптимизации -O, но это не улучшило производительность.

Качество жизни


В этом разделе рассматриваются относительные плюсы и минусы, связанные с удобством и простотой использования каждого языка. Люди недооценивают усилия, затрачиваемые на повседневное использование языка; необходима значительная поддержка и инфраструктура, поэтому стоит сравнить различные аспекты каждого языка. Читателям, стремящимся избежать TLDR, следует прокрутить до конца данного раздела до таблицы, в которой сравниваются обсуждаемые здесь особенности языка. Было сделано все возможное, чтобы быть как можно более объективным, но сравнение языков программирования является сложным, предвзятым и спорным, поэтому читайте этот раздел с учетом этого. Некоторые рассматриваемые элементы, такие как массивы, рассматриваются с точки зрения Data Science/технических/научных вычислений, а другие являются более общими.

Интерактивность


Программистам нужен быстрый цикл кодирования/компиляции/результата во время разработки, чтобы быстро наблюдать за результатами и выводами для того, чтобы двигаться вперёд либо вносить необходимые изменения. Интерпретатор Julia самый лучший для этого и предлагает гладкую и многофункциональную разработку, а D близок к этому. Этот цикл кодирования/компиляции/результата может быть медленным даже при компиляции небольшого кода. В D есть три компилятора: стандартный компилятор DMD, LLVM-компилятор LDC и GCC-компилятор GDC. В этом процессе разработки использовались компиляторы DMD и LDC. DMD компилирует очень быстро, что очень удобно для разработки. А LDC отлично справляется с созданием быстрого кода. Компилятор Chapel очень медленный по сравнению с ним. В качестве примера запустим Linux time для компилятора DMD и для Chapel для нашего кода матрицы без оптимизаций. Это дает нам для D:
real0m0.545suser0m0.447ssys0m0.101s

Сравним с Chapel:
real0m5.980suser0m5.787ssys0m0.206s

Это большая фактическая и психологическая разница, она может заставить программистов неохотно проверять свою работу и откладывать цикл разработки, если им приходится ждать результатов, особенно когда исходный код увеличивается в объеме и время компиляции становится существенным.

Стоит однако отметить, что при разработке пакетов в Julia время компиляции может быть очень долгим, и пользователи отмечают, что при загрузке нескольких пакетов время компиляции может растягиваться. Поэтому практика цикла разработки в Julia может варьироваться, но в данном конкретном случае процесс был безупречным.

Документация и примеры


Один из способов сравнить документацию на разных языках это сравнить их все с официальной документацией Python, которая является золотым стандартом для языков программирования. Она сочетает примеры с формальными определениями и инструкциями в простом и удобном для пользователя виде. Поскольку многие программисты знакомы с документацией на Python, такой подход даёт представление о том, как они сравнивают.

Документация Julia наиболее близка по качеству к документации на Python и даёт пользователю очень плавный, детальный и относительно безболезненный переход на язык. Она также имеет богатую экосистему блогов, и темы по многим аспектам языка легкодоступны. Официальная документация D не так хороша и может быть сложной и разочаровывающей, однако существует очень хорошая бесплатная книга Программирование на D, которая является отличным введением в язык, но ни одна единичная книга не может охватить язык программирования целиком и не так много исходных текстов примеров для продвинутых тем. Документация Chapel достаточно хороша для того, чтобы сделать что-то, хотя представленные примеры различаются по наличию и качеству. Часто программисту требуется знать, где искать. Хорошая тема для сравнения библиотеки файлового ввода/вывода в Chapel, D и Julia. Библиотека ввода/вывода Chapel содержит слишком мало примеров, но относительно ясна и проста; ввод/вывод D распределён по нескольким модулям, и документации более сложно следовать; документация по вводу/выводу Julia содержит много примеров, и она ясна и проста для понимания.

Возможно, одним из факторов, влияющих на принятие Chapel, является отсутствие примеров поскольку массивы имеют нестандартный интерфейс, пользователю приходится потрудиться, чтобы их узнать. Несмотря на то, что документация в D может быть не так хороша местами, язык имеет много сходств с Си и Си++, потому ему сходит с рук более скудная документация.

Поддержка многомерных массивов


Массивы здесь относятся не к массивам в стиле С и С++, доступным в D, а к математическим массивам. Julia и Chapel поставляются с поддержкой массивов, а D нет, но у него есть библиотека Мир, которая содержит многомерные массивы (ndslice). В реализации расчета ядра матрицы я написал свой объект матрицы в D, что несложно, если понимать принцип, но это не то, что хочет делать пользователь. Тем не менее, в D есть линейная библиотека алгебры Lubeck, которая обладает впечатляющими характеристиками производительности и интерфейсами со всеми обычными реализациями BLAS. Массивы Julia, безусловно, самые простые и знакомые. Массивы Chapel сложнее для начального уровня, чем массивы Julia, но они спроектированы для запуска на одноядерных, многоядерных системах и компьютерных кластерах с использованием единого или очень похожего кода, что является хорошей уникальной точкой притяжения.

Мощность языка


Поскольку Julia это динамический язык программирования, некоторые могут сказать: Ну, Julia это динамический язык, который гораздо более разрешительный, чем статические языки программирования, поэтому дебаты закончены, но все гораздо сложнее. В статических системах типов есть свое могущество. У Julia есть система типов, похожая по своей природе на системы типов из статических языков, так что вы можете писать код так, как если бы вы использовали статический язык, но вы можете делать вещи, зарезервированные только для динамических языков. Она имеет высокоразвитый синтаксис общего и мета-программирования, а также мощные макросы. Она также имеет очень гибкую объектную систему и множественную диспетчеризацию. Это сочетание возможностей делает Julia самым мощным языком из трех.

D был задуман как замена C++ и взял очень много от C++ (а также заимствовал из Java), но делает шаблонное программирование и вычисления времени компиляции (CTFE) намного более удобными для пользователя, чем в C++. Это язык с одиночной диспетчеризацией (хотя есть пакет с мультиметодами). Вместо макросов в D есть mixin для строк и шаблонов, которые служат аналогичной цели.

Chapel имеет поддержку дженериков и зарождающуюся поддержку для ООП с одиночной диспетчеризацией, в нем нет поддержки макросов, и в этих вопросах он ещё не так зрел, как D или Julia.

Конкурентность и параллельное программирование


В настоящее время новые языки изначально поддерживают конкурентность с ее популярным подмножеством параллелизмом, но детали сильно варьируются в зависимости от языка. Параллелизм более актуален в этом примере, и все три языка предоставляют его. Написать параллельные циклы просто во всех трех языках.

В модели параллелизма Chapel гораздо больше внимания уделяется параллелизму данных, но есть инструменты для параллелизма задач и предоставляется поддержка параллелизма на основе кластеров.

Julia имеет хорошую поддержку как конкурентности, так и параллелизма.

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

Стандартная библиотека


Насколько хороша стандартная библиотека всех трех языков в целом? Какие задачи она позволяют пользователям легко выполнять? Это сложный вопрос, потому что при этом учитываются качество библиотеки и фактор документирования. Все три языка имеют очень хорошие стандартные библиотеки. В D самая полная стандартная библиотека, но Julia отличная вторая, потом Chapel, но все никогда не бывает так просто. Например, пользователь, желающий написать бинарный ввод/вывод, может найти Julia самой простой для начинающего; она имеет самый простой, понятный интерфейс и документацию, за ней следует Chapel, а затем D. Хотя в моей реализации программы для чтения IDX-файлов, ввод/вывод D был самым быстрым, но зато код Julia было легко написать для случаев, недоступных на двух других языках.

Менеджеры и экосистема пакетов


С точки зрения документации, использования и возможностей, менеджер пакетов D Dub является наиболее полным. D также имеет богатую экосистему пакетов на веб-сайте Dub, зато менеджер пакетов Julia тесно интегрирован с GitHub и является хорошей пакетной системой с хорошей документацией. У Chapel есть менеджер пакетов, но нет высокоразвитой экосистемы.

Интеграция с Cи


Cи- интероперабельность проста в использовании на всех трех языках; Chapel имеет хорошую документацию, но не так популярен, как другие. Документация на языке D лучше, а документация на языке Julia самая полная. Однако, как ни странно, ни в одной документации по языкам нет команд, необходимых для компиляции вашего собственного кода на C и его интеграции с языком, что является недосмотром, особенно когда дело касается новичков. Тем не менее, в D и Julia легко искать и найти примеры процесса компиляции.

Сообщество


Для всех трех языков есть удобные места, где пользователи могут задавать вопросы. Для Chapel, самое простое место это Gitter, для Julia это Discourse (хотя есть и Julia Gitter), а для D это официальный форум сайта. Julia сообщество является наиболее активным, а затем D, а затем Chapel. Я обнаружил, что вы получите хорошие ответы от всех трех сообществ, но вы, вероятно, получите более быстрые ответы по D и Julia.
Chapel D Julia
Компиляция/ Интерактивность Медленная Быстрая Лучшая
Документация & Примеры Детальные Лоскутные Лучшие
Многомерные массивы Да Только родные
(библиотека)
Да
Мощность языка Хорошая Отличная Лучшая
Конкурентность & Параллелизм Отличная Отличная Хорошая
Стандартная библиотека Хорошая Отличная Отличная
Пакетный менеджер & Экосистема Зарождающаяся Лучшая Отличная
Си -Интеграция Отличная Отличная Отличная
Сообщество Маленькое Энергичное Наибольшее
Таблица характеристик качества жизни для Chapel, D & Julia

Резюме


Если вы начинающий программист, пишущий числовые алгоритмы и выполняющий научные вычисления, и хотите быстрого и простого в использовании языка, Julia это ваш лучший выбор. Если вы опытный программист, работающий в той же области, Julia все еще является отличным вариантом. Если вы хотите более традиционный, промышленный, статически скомпилированный, высокопроизводительный язык со всеми свистоперделками, но хотите что-то более продуктивное, безопасное и менее болезненное, чем C++, то D это ваш лучший вариант. Вы можете написать что угодно в D и получить отличную производительность благодаря его компиляторам. Если вам нужно, чтобы вычисления массивов происходили на кластерах, то Chapel, наверное, самое удобное решение.

С точки зрения грубой производительности в этой задаче, D был победителем, явно демонстрируя лучшую производительность в 5 из 9 эталонных задач. Исследование показало, что ярлык Julia как высокопроизводительного языка это нечто большее, чем просто шумиха он обладает собственными достоинствами в сравнении с высококонкурентными языками. Было сложнее, чем ожидалось, получить конкурентоспособную производительность от Chapel команде Chapel потребовалось много исследований, чтобы придумать текущее решение. Тем не менее, по мере того, как язык Chapel взрослеет, мы сможем увидеть дальнейшее улучшение.
Подробнее..

Перевод Решение проблемы N1 запроса без увеличения потребления памяти в Laravel

28.06.2020 00:17:28 | Автор: admin

Одна из основных проблем разработчиков, когда они создают приложение с ORM это N+1 запрос в их приложениях. Проблема N+1 запроса это не эффективный способ обращения к базе данных, когда приложение генерирует запрос на каждый вызов объекта. Эта проблема обычно возникает, когда мы получаем список данных из базы данных без использования ленивой или жадной загрузки (lazy load, eager load). К счастью, Laravel с его ORM Eloquent предоставляет инструменты, для удобной работы, но они имеют некоторые недостатки.
В этой статье рассмотрим проблему N+1, способы ее решения и оптимизации потребления памяти.


Давайте рассмотрим простой пример, как использовать eager loading в Laravel. Допустим, у нас есть простое веб-приложение, которое показывает список заголовков первых статей пользователей приложения. Тогда связь между нашими моделями может быть вроде такой:


Class User extends Authenticatable{    public function articles()    {        return $this->hasMany(Aricle::class, 'writter_id');    }}

и тогда простое действие получения данных из базы данных и передачи в шаблон может выглядеть таким образом:


Route::get('/test', function() {    $users = User::get();    return view('test', compact('users'));});

Простой шаблон test.blade.php для отображения списка пользователей с соответствующими заголовками их первой статьи:


@extends('layouts.app')@section('content')<ul>    @foreach($users as $user)        <li>Name: {{ $user->name}}</li>        <li>First Article: {{ $user->articles()->oldest()->first()->title }}</li>    @endforeach</ul>@endsection

И когда мы откроем нашу тестовую страницу в браузере, мы увидим нечто подобное:


image


Я использую debugbar (https://github.com/barryvdh/laravel-debugbar), чтобы показать, как выполняется наша тестовая страница. Для отображения этой страницы вызывается 11 запросов в БД. Один запрос для получения всей информации о пользователях и 10 запросов, чтобы показать заголовок их первой статьи. Видно, что 10 пользователей создают 10 запросов в базу данных к таблице статей. Это называется проблемой N+1 запроса.


Решение проблемы N+1 запроса с жадной загрузкой


Вам может показаться, что это не проблема производительности вашего приложения в целом. Но что, если мы хотим показать больше чем 10 элементов? И часто, нам также приходится иметь дело с более сложной логикой, состоящего из более чем одного N+1 запроса на странице. Это условие может привести к более чем 11 запросам или даже к экспоненциально растущему количеству запросов.


Итак, как мы это решаем? Есть один общий ответ на это:


Eager load


Eager load (жадная загрузка) это процесс, при котором запрос для одного типа объекта также загружает связанные объекты в рамках одного запроса к базе данных. В Laravel мы можем загружать данные связанных моделей используя метод with(). В нашем примере мы должны изменить код следующим образом:


Route::get('/test', function() {    $users = User::with('articles')->get();    return view('test', compact('users'));});

@extends('layouts.app')@section('content')<ul>    @foreach($users as $user)        <li>Name: {{ $user->name}}</li>        <li>First Article: {{ $user->articles()->sortBy('created_at')->first()->title }}</li>    @endforeach</ul>@endsection

И, наконец, уменьшить количество наших запросов до двух:


image


Также мы можем создать связь hasOne, с соответствующим запросом для получения первой статьи пользователя:


    public function first_article()    {        return $this->hasOne(Aricle::class, 'writter_id')->orderBy('created_at', 'asc');    }

Теперь мы можем загрузить ее вместе с пользователями:


Route::get('/test', function() {    $users = User::with('first_article')->get();    return view('test', compact('users'));});

Результат теперь выглядит следующим образом:


image


Итого, мы можем уменьшить количество наших запросов и решить проблему N+1 запроса. Но хорошо ли мы улучшили нашу производительность? Ответом может быть "нет"! Это правда, что мы уменьшили количество запросов и решили проблемы N+1 запроса, но на самое деле мы добавили новую неприятную проблему. Как вы видите, мы уменьшили количество запросов с 11 до 2, но мы также увеличили количество загружаемых моделей с 20 до 10010. Это означает, чтобы показать 10 пользователей и 10 заголовков статей мы загружаем 10010 объектов Eloquent в память. Если у вас не ограничена память, то это не проблема. Иначе вы можете положить ваше приложение.


Жадная загрузка динамических отношений


Должно быть 2 цели при разработке приложения:


  1. Мы должны сохранять минимальное количество запросов в БД
  2. Мы должны сохранять минимальное потребление памяти

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


Для реализации динамических отношений, мы будем напрямую использовать primary key вместо его foreign key. Мы также должны использовать подзапрос в связанной таблице, чтобы получить соответствующий идентификатор. Подзапрос будет размещен в select на основе отфильтрованных данных связанной таблицы.


Пример получения пользователей и id их первых статей через подзапрос:


select     "users".*,    (        select "id"         from "article"        where "writter_id" = "users"."id"        limit 1    ) as "first_article_id"from "users"

Мы можем получить такой запрос, если добавим select в подзапрос в нашем query builder. С использованием Eloquent это можно написать следующим образом:


User::addSelect(['first_article_id' => Article::select('id')                ->whereColumn('writter_id', 'users.id')                ->orderBy('created_at', 'asc')                ->take(1)    ])->get()

Этот код генерирует такой же sql запрос, что и в примере выше. После этого мы сможем использовать связь "first_article_id" для получения первых статей пользователя. Чтобы сделать наш код чище, мы можем использовать query scope Eloquent, чтобы упаковать наш код и выполнить жадную загрузку для получения первой статьи. Таким образом, мы должны добавить следующий код в класс модели User:


Class User extends Authenticatable{    public function articles()    {        return $this->hasMany(Aricle::class, 'writter_id');    }    public function first_article()    {        return $this->hasOne(Aricle::class, 'writter_id')->orderBy('created_at', 'asc');    }    public function scopeWithFirstArticle($query)    {        $query->addSelect(['first_article_id' => Article::select('id')                ->whereColumn('writter_id', 'users.id')                ->orderBy('created_at', 'asc')                ->take(1)        ])->with('first_article')    }}

И наконец, давайте изменим наш контроллер и шаблон. Мы должны использовать scope в нашем контроллере для жадной загрузки первой статьи. И мы можем напрямую обращаться к переменной first_article в нашем шаблоне:


Route::get('/test', function() {    $users = User::withFirstArticle()->get();    return view('test', compact('users'));});

@extends('layouts.app')@section('content')<ul>    @foreach($users as $user)        <li>Name: {{ $user->name}}</li>        <li>First Article: {{ $user->first_article->title }}</li>    @endforeach</ul>@endsection

Ниже результат производительности страницы после внесения этих изменений:


image


Теперь наша страница содержит всего 2 запроса и загружает 20 моделей. Мы достигли обеих целей оптимизации количества запросов к БД и минимизации потребления памяти.


Ленивая загрузка динамических отношений


Наша динамическая связь не будет работать хорошо автоматически. Сначала мы должны загрузить подзапрос для создания этой связи. Что, если мы хотим использовать ленивую подгрузку первых статей пользователя в одном месте и иметь жадную загрузку статей в другом месте?


Для этого нам нужно добавить небольшой хинт в наш ход, добавив accessor для свойства первой статьи:


public function getFirstArticleAtribute(){    if(!array_key_exists('first_article', $this->relations)) {        $this->setRelation('first_article', $this->articles()->oldest()->first());    }    return $this->getRelation('first_article');}

В действительности, мы не реализовывали ленивую загрузку для динамической связи. Мы просто назначили результат выполнения запроса получения первой статьи пользователя. Это должно работать одинаково хорошо при обращении к свойству first_article как для жадной загрузки, так и для ленивой загрузки.


Динамические связи в Laravel 5.X


К сожалению, наше решение применимо только к Laravel 6 и выше. Laravel 6 и предыдущие версии используется разная реализация addSelect. Для использования в более старых версиях фреймворка мы должны изменить наш код. Мы должны использовать selectSub для выполнения подзапроса:


public function scopeWithFirstArticle($query){    if(is_null($query->toBase()->columns)) {        $query->select([$query->toBase()->from . '.*']);    }    $query->selectSub(                Article::select('id')                ->whereColumn('writter_id', 'users.id')                ->orderBy('created_at', 'asc')                ->take(1)                ->toSql(),             'first_article_id'            )->with('first_article');}
Подробнее..

Перевод Почему повышение тока на AMD Ryzen не убьёт ваш процессор

29.06.2020 10:05:23 | Автор: admin


Если кто-то хочет повысить быстродействие CPU, обычно он находит способ сделать это. Будь то пользователь, самостоятельно разгоняющий свой компьютер, или же производители материнских плат, подстраивающие настройки для улучшения быстродействия ЦП ещё перед продажей в итоге всем хочется увеличить быстродействие, и по множеству причин. Эта ненасытная жажда максимального быстродействия означает, однако, что некоторые из этих подстроек и изменений могут вывести ЦП за пределы спецификаций. В итоге часто можно видеть методы, выполняющие обещания по увеличению скорости работы за счёт увеличения температуры или сокращения времени жизни железа.

В этой связи стоит рассмотреть появившуюся недавно информацию о том, что производители материнских плат играют с настройками тока, подаваемого на процессоры от AMD. Увеличивая его, они увеличивают и потенциальную мощность процессора, что в итоге приводит к увеличению не только скорости работы, но и температуры. Такой подход к подстройке железа нельзя назвать новым, однако недавние события вызвали волну замешательства, вопросов о том, что происходит на самом деле, и какие последствия это может повлечь для процессоров AMD Ryzen. Чтобы прояснить эту ситуацию, мы решили сделать данный обзор.

Старомодные способы: методы расширения спектра, мультиядерные улучшения, PL2


За время работы редактором по материнским платам, а потом и по CPU, я постоянно сталкиваюсь с ухищрениями, на которые производители материнок готовы идти ради того, чтобы вырваться вперёд по быстродействию в гонке с конкурентами. Мы первыми рассказали о такой настройке, как мультиядерное улучшение [MultiCore Enhancement], появившейся в августе 2012 года, и выставляющей рабочую частоту всех ядер выше той, что указана в спецификациях, а иногда и откровенно разгоняющей рабочую частоту. Однако производители материнских плат занимались подстройкой разных свойств, связанных с быстродействием, и задолго до этого. Можно вспомнить метод расширения спектра с увеличением базовой частоты со 100 МГц до 104,7 МГц, благодаря которому увеличивалось быстродействие на поддерживающих его системах.

В последнее время на платформах Intel видны попытки производителей по увеличению пределов мощности с тем, чтобы материнские платы выдерживали турборежим работы как можно дольше и только потому, что производители материнских плат перестраховываются при разработке обеспечения питания компонентов. За последние пару недель мы обнаружили примеры того, как некоторые производители материнских плат просто игнорируют новые требования Intel Thermal Velocity Boost.

Короче говоря, каждый производитель материнских плат хочет быть лучшим, и для этого часто размываются пределы того, что считается базовыми спецификациями процессора. Мы довольно часто писали о том, что граница между спецификациями и рекомендуемыми настройками может быть размытой. Для Intel мощность в режиме турбо, указанное в документации, является рекомендуемой настройкой, и любое значение, установленное на материнских платах, технически укладывается в спецификации. Судя по всему, Intel считает разгоном только увеличение частоты режима турбо.

Подстройка материнских плат с разъёмом AM4


Теперь мы переходим к новостям производители материнских плат пытаются подстроить материнские платы Ryzen так, чтобы выжать из них больше быстродействия. Как подробно объяснялось на форумах HWiNFO, у платформ АМ4 обычно есть три ограничения: Package Power Tracking (PPT), обозначающее максимальную мощность, которую можно подавать на разъём; Thermal Design Current (TDC), или максимальный ток, подводимый к регуляторам напряжения в рамках тепловых ограничений; Electrical Design Current (EDC), или максимальный ток, который в принципе может подаваться на регуляторы напряжения. Некоторые из этих показателей сравниваются с метриками, получаемыми внутри процессора или снаружи, в сети подачи питания, с целью проверки превышения пороговых значений.

Чтобы подсчитать параметры программного управления питанием, с которым сравнивается РРТ, сопроцессор управления питанием получает значение тока от управляющего контроллера регулятора напряжения. Это не реальное значение силы тока, а безразмерная величина от 0 до 255, где 0 это 0 А, а 255 максимальное значение тока, которое может обработать модуль регулятора напряжения. Затем сопроцессор управления питанием проводит свои подсчёты (мощность в ваттах = напряжение в вольтах, умноженное на ток в амперах).

Этот безразмерный диапазон нужно калибровать для каждой материнской платы, в зависимости от её схемы и используемых компонентов а также дорожек, слоёв и качества в целом. Чтобы получить точное значение коэффициента масштаба, производитель материнских плат должен тщательно замерить правильные показатели, а потом написать прошивку, которая будет использовать эту таблицу в подсчётах мощности.

Это означает, что в принципе существует способ поиграться с тем, как система интерпретирует пиковую мощность процессора. Производители материнских плат могут уменьшать это безразмерное значение тока, чтобы процессор и сопроцессор управления питанием считали, что на процессор подаётся меньше мощности, и в итоге ограничитель PPT не активировался. Это позволяет процессору работать в режиме турбо, превосходящем то, что изначально планировали в AMD.

У этого есть несколько последствий. Процессор будет потреблять больше энергии, в основном в виде увеличения тока. Это приведёт к повышению теплоотдачи. Поскольку процессор работает быстрее (потребляя больше энергии, чем считает ПО), он покажет лучшие результаты в тестах на быстродействие.

Если у вашего процессора базовая TDP 105 Вт, а PPT равняется 142 Вт, то при нормальных условиях стоит ожидать, что на заводских настройках процессора будет рапортовать о потреблении 142 Вт. Однако если установить безразмерный показатель тока на 75% от реального, то реально он будет потреблять в районе 190 Вт = 142/0,75. Если остальные ограничения не затронуты, то процессор будет рапортовать о 75% от PPT, что будет запутывать пользователя.

Выход ли это за рамки спецификаций?


Если считать, что PPT, TDC и EDC являются основой спецификаций AMD для потребления мощности и тока, то да, это выходит за рамки спецификаций. Однако PPT по своей природе выходит за рамки TDP, поэтому тут мы уже попадаем в загадочный мир определений понятия турбо.

Как мы уже обсуждали ранее касательно мира Intel, пиковое потребление энергии в режиме турбо Intel сообщает производителям материнских плат только в качестве рекомендованного значения. В итоге чипы от Intel примут любое значение в качестве пикового энергопотребления, как разумные величины типа 200 Вт или 500 Вт, так и безумные, типа 4000 Вт. Чаще всего (и в зависимости от процессора), чип упирается в другие ограничения. Но в случае с самыми мощными моделями этот параметр стоит отслеживать. Значение тау, обозначающее длительность нахождения в режиме турбо, и определяющее объём ведра с энергией, из которого режим турбо её черпает, тоже можно увеличить. Вместо значения по умолчанию из диапазона от 8 до 56 секунд, тау можно увеличивать практически до бесконечности. Согласно Intel, всё это укладывается в спецификации если производители материнских плат могут делать материнские платы, обеспечивающие все эти показатели.

Intel считает, что настройки выходят за рамки спецификаций, когда частота работы процессора выходит за пределы таблиц турбо режима для Turbo Boost 2.0 (или TBM 3.0, или Thermal Velocity Boost). Когда процессор выходит за эти пределы, Intel считает это разгоном, и считает себя свободной от выполнения гарантийных обязательств.

Проблема в том, что если попытаться перенести те же правила на ситуацию с AMD, то у AMD нет турбо-таблиц как таковых. Процессоры AMD работают, предлагая наибольшую возможную частоту в зависимости от ограничений по току и мощности в любой момент времени. При увеличении количества задействованных в работе ядер уменьшается энергопотребление каждого отдельного ядра, и вслед за ним и общая частота. И тут мы углубляемся в детали по отслеживанию огибающей частоты, и всё усложняется из-за того, что AMD может менять частоту шагами по 25 МГц в отличие от Intel, использующей шаги по 100 МГц.

Также AMD использует возможности, выводящие частоту работы чипа за пределы турбо-частоты, описанные в спецификации. Если вы считаете, что это разгон, и судите только по цифрам на коробке тогда, да, это разгон. AMD в данном случае специально запутывает ситуацию, однако плюсом можно считать некоторое повышение быстродействия.

Подвергается ли мой процессор опасности?


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

У большинства современных процессов х86 есть либо трёхгодовая гарантия для ритейл-версий в коробочках, либо годовая на ОЕМ. И хотя AMD и Intel не будут менять вам процессор по окончанию этого периода, ожидается, что большая часть процессоров будет работать не менее 15 лет. Мы до сих пор тестируем разные старые процессоры в старых материнских платах, несмотря на то, что их уже давно не обслуживают (и чаще всего проблема заключается во вздувшихся конденсаторах на материнской плате, а не в процессоре).

Когда с конвейера сходит подложка с процессора, компания получает отчёт о надёжности, что помогает определить потенциальное применение для этих процессоров. Сюда входят и такие показатели, как реагирование на изменение напряжения и частоты, а также подверженность электромиграции.

Кроме физического повреждения или перегрева при отключении предела нагрева, главным способом повредиться у современного процессора будет электромиграция. В этом процессе электроны пробираются через проводники процессора и сталкиваются с атомами кремния (и других элементов), в результате выбивая их из кристаллической решётки. Само по себе это редкое явление (вспомните, к примеру, как давно работает проводка в вашем доме), однако на мелких масштабах оно может влиять на работу процессора.



После смещения атома металла в проводнике с его места в кристаллической решётке сечение проводника в этом месте уменьшается. Это увеличивает его сопротивление, поскольку оно обратно пропорционально сечению. Если выбить достаточно атомов кремния, то проводник перестанет проводить ток, и процессор уже нельзя будет использовать. Этот процесс происходит и в транзисторах там его называют старением транзистора, из-за чего транзистору с течением времени требуется всё большее напряжение (дрейф напряжения).

При определённых условиях электромиграция идёт быстрее всё зависит от температуры, использования компонента и напряжения. Один из основных способов справиться с увеличившимся сопротивлением увеличить напряжение, что в свою очередь увеличивает температуру процессора. В итоге образуется замкнутый круг, из-за которого эффективность процессора со временем падает.

При повышении напряжения (и энергии электрона) и плотности тока (электронов на площадь сечения) шансы электромиграции возрастают. При повышении температуры ситуация может ухудшиться. Все эти факторы влияют на то, сколько электронов могут запастись энергией, достаточной для осуществления электромиграции.

Неблагоприятный процесс, не правда ли? Раньше так и было. При постепенном усовершенствовании производственного процесса и схем работы логических вентилей производители применяли контрмеры, уменьшающие уровень электромиграции. При уменьшении характерных размеров и напряжения этот эффект также становится всё менее заметным ведь площадь сечения проводников также уменьшается.

Довольно долго большая часть потребительской электроники не страдала от электромиграции. Единственный раз, когда я лично столкнулся с электромиграцией это когда у меня был процессор Core i7-2600K Sandy Bridge 2011 года, который я разгонял на соревнованиях до 5,1 ГГц с использованием серьёзного охлаждения. В итоге он дошёл до такого состояния, что через пару лет работы ему для нормального функционирования требовалось большее напряжение.

Но тот процессор я гонял в хвост и гриву. Современное оборудование разработано так, чтобы работать десятилетие или более. Судя по отчётам, увеличение нагрева с увеличением энергопотребление оказывается не таким уж и большим. В отчёте Стилта указано, что процессор, видя наличие доступной мощности, немного увеличивает напряжение, чтобы получить прирост в 75 МГц, что увеличивает напряжение с 1,32 до 1,38 во время прогона теста CineBench R20. Пиковое напряжение, значимое для электромиграции, увеличивается всего лишь от 1,41 до 1,42. Общая мощность растёт на 25 Вт нельзя сказать, что на порядок.

Так что, если моя материнская плата каким-то образом подстроит это воспринимаемое значение тока, не превратится ли мой процессор в кирпич? Нет. Если только у вас не будет каких-то серьёзных ошибок при сборке (например, в системе охлаждения). Всё предполагаемое время жизни продукта, и ещё лет десять после этого, вряд ли эта подстройка будет иметь какое-то значение. Как уже упоминалось, если бы даже это влияло на электромиграцию, то производители процессора встроили механизмы для того, чтобы противодействовать ей. Единственный способ следить за развитием электромиграции это отслеживать средние и пиковые значения напряжения годами, и смотреть, подстраивает ли процессор автоматически эти параметры для компенсации.

Стоит отметить, что безразмерный показатель силы тока конечный пользователь подстраивать не может им управляет материнская плата через обновления в BIOS. Если вы занимаетесь разгоном, то вы влияете на электромиграцию гораздо сильнее, чем эта подстройка. Если кто-то из вас беспокоится о температурных режимах, я думаю, что это как раз те люди, которые уже отслеживают и подстраивают пределы параметров в BIOS.

Как узнать, занимается ли этим моя материнская плата


Во-первых, нужно использовать стоковую систему. Если параметры PPT/TDC/EDC изменены, то система уже подстроена по-другому, поэтому сконцентрируемся только на тех пользователях, которые работают со стоковыми системами.

Затем нужно установить последнюю версию HWiNFO и тест, загружающий систему на 100%, к примеру, CineBench R20.

В HWiNFO есть метрика под названием CPU Power Reporting Deviation [отклонение энергопотребления процессора]. Наблюдайте за этим числом, когда система находится под нагрузкой. У нормальной материнской платы число будет равно 100%, а у материнской платы с подстроенным током или регуляторами напряжения этот показатель будет меньше 100%.



Уточню, что это работает, только если:
  1. Ваш AMD Ryzen работает на полностью заводских настройках, установленных в BIOS. Никаких настроек в ОС и изменения ограничений по энергопотреблению или току.
  2. Когда ваш процессор загружен на 100%.


Если это не так, то значение параметра Power Reporting Deviation ничего не значит. Если же эти условия выполнены, а показатель падает ниже 100%, то ваша материнская плата изменяет работу процессора.

Какие у меня есть варианты?


Если ваша материнская плата пытается выжать из процессора больше, чем надо, однако вас устраивает температурный режим и энергопотребление компьютера, то просто наслаждайтесь дополнительным быстродействием. Даже если это всего лишь дополнительные 75 МГц.

С AMD это никак не связано, поскольку вся ответственность ложится на производителей материнских плат. Пользователи могут захотеть обратиться к производителю материнских плат и попросить прислать обновление для BIOS. Если пользователь захочет вернуть такую материнскую плату в магазин, ему нужно уточнить этот вопрос у продавца.

Хотя такое поведение вроде бы нарушает спецификации PPT, на самом деле оно не выходит за (плохо обозначенные) пределы частот. Эта ситуация похожа на то, как производители материнских плат играются с ограничениями мощности на системах от Intel. Однако, возможно, было бы приятно иметь в BIOS опцию, которая позволяла бы включать и выключать такое поведение.
Подробнее..

Перевод Dont Fear the Reaper

03.07.2020 00:15:20 | Автор: admin

D, как и многие активно используемые сегодня языки, поставляется со сборщиком мусора (Garbage Collector, GC). Многие виды ПО можно разрабатывать, вообще не задумываясь о GC, в полной мере пользуясь его преимуществами. Однако у GC есть свои изъяны, и в некоторых сценариях сборка мусора нежелательна. Для таких случаев язык позволяет временно отключить сборщик мусора или даже совсем обойтись без него.


Чтобы получить максимальное преимущество от сборщика мусора и свести недостатки к минимуму, необходимо хорошо понимать, как работает GC в языке D. Хорошим началом будет страничка Garbage Collection на dlang.org, которая подводит обоснование под GC в языке D и даёт несколько советов о том, как с ним работать. Это первая из серии статей, которая призвана более подробно осветить тему.


В этот раз мы коснёмся только самых основ, сосредоточившись на функциях языка, которые могут вызвать выделение памяти через GC. Будущие статьи представят способы отключить GC при необходимости, а также идиомы, помогающие справляться с его недетерминированностью (например, управление ресурсами в деструкторах объектов, находящихся под контролем GC).


Самая первая вещь, которую нужно уяснить: сборщик мусора в D запускается только во время выделения памяти и только в том случае, если нет памяти, которую можно выделить. Он не сидит в фоне, периодически сканируя кучу и собирая мусор. Это необходимо понимать, чтобы писать код, эффективно использующий память под контролем GC. Рассмотрим следующий пример:


void main() {    int[] ints;    foreach(i; 0..100) {        ints ~= i;    }}

Эта программа создаёт динамический массив значений типа int, а затем при помощи имеющегося в D оператора присоединения добавляет в него числа от 0 до 99 в цикле foreach. Что неочевидно неопытному глазу, так это то, что оператор присоединения выделяет память для добавляемых значений через сборщик мусора.


Реализация динамического массива в рантайме D вовсе не тупая. В нашем примере не произойдёт сотни выделений памяти, по одному на каждое значение. Когда требуется больше памяти, массив выделяет больше памяти, чем запрашивается. Мы можем определить, сколько на самом деле будет выделений памяти, задействовав свойство capacity. Это свойство возвращает количество элементов, которые можно поместить в массив, прежде чем потребуется выделение памяти.


void main() {    import std.stdio : writefln;    int[] ints;    size_t before, after;    foreach(i; 0..100) {        before = ints.capacity;        ints ~= i;        after = ints.capacity;        if(before != after) {            writefln("Before: %s After: %s",                before, after);        }    }}

Скомпилировав этот код в DMD 2.073.2 и выполнив, мы увидим шесть сообщений шесть выделений памяти через GC. Это означает, что у GC шесть раз была возможность собрать мусор. В этом маленьком примере это вряд ли произошло. Если бы этот цикл был частью большой программы, использующей GC, то это вполне могло бы произойти.


Кроме того, любопытно взглянуть на значения before и after. Программа выдаёт последовательность: 0, 3, 7, 15, 31, 63, и 127. После выполнения цикла массив ints содержит 100 значений, и в нём есть место под ещё 27 значений, прежде чем произойдёт следующее выделение памяти, которое увеличит объём массива до 255, экстраполируя предыдущие значения. Это, однако, уже детали реализации рантайма D, и в будущих релизах всё может поменяться. Чтобы узнать больше о том, как GC контролирует массивы и срезы, взгляните на прекрасную статью Стива Швайхоффера (Steve Schveighoffer) на эту тему.


Итак, шесть выделений памяти, шесть возможностей для GC приостановить выполнение кода на неопределённое время даже внутри этого маленького цикла. Насколько это плохо, зависит от того, находится ли этот цикл в горячей части кода и сколько всего памяти выделяется из кучи сборщика мусора. Но даже это не обязательно означает, что нужно отключать GC в этой части программы.


Даже когда речь идёт о языках без встроенного сборщика мусора, таких как C и C++, большинство программистов рано или поздно узнают, что для общей производительности лучше заранее выделить как можно больше ресурсов и свести к минимуму выделение памяти во внутренних циклах. Это одна из многих преждевременных оптимизаций, которые не является корнем всех зол то, что мы называем лучшими практиками. Учитывая, что GC в языке D запускается только когда происходит выделение памяти, ту же самую стратегию можно применять как простой способ минимизировать его влияние на производительность. Вот как можно переписать пример:


void main() {    int[] ints = new int[](100);    foreach(i; 0..100) {        ints[i] = i;    }}

Мы сократили шесть выделений памяти до одного. Единственная возможность для GC запуститься перед внутренним циклом. Этот код выделяет место для по крайней мере 100 элементов и инициализирует их значением нулями перед входом в цикл. После new длина массива будет 100, но в нём почти наверняка будет дополнительная ёмкость.


Есть также другой способ: функция reserve:


void main() {    int[] ints;    ints.reserve(100);    foreach(i; 0..100) {        ints ~= i;    }}

Это выделит память под по крайней мере 100 значений, но массив всё ещё будет пустым (его свойство length будет возвращать 0), так что ничего не будет инициализировано значениями по умолчанию. Учитывая, что цикл добавляет только 100 значений, гарантируется, что выделения памяти не произойдёт.


Помимо new и reserve, можно также выделять память явным образом, напрямую вызывая GC.malloc.


import core.memory;void* intsPtr = GC.malloc(int.sizeof * 100);auto ints = (cast(int*)intsPtr)[0 .. 100];

Литералы массивов обычно выделяет память.


auto ints = [0, 1, 2];

Это верно также в том случае, когда литерал массива используется в enum.


enum intsLiteral = [0, 1, 2];auto ints1 = intsLiteral;auto ints2 = intsLiteral;

Значение типа enum существует только во время компиляции и не имеет адреса в памяти. Его имя синоним его значения. Где бы вы его не использовали, это будет как если бы вы скопировали и вставили его значение на месте его имени. И inst1, и inst2 вызовут выделение памяти, как если бы мы определили их вот так:


auto ints1 = [0, 1, 2];auto ints2 = [0, 1, 2];

Литералы массивов не выделяют память, если целью является статический массив. Кроме того, строковые литералы (строки в D это массивы) исключение из общего правила и также не выделяют память.


int[3] noAlloc1 = [0, 1, 2];auto noAlloc2 = "No Allocation!";

Оператор конкатенации всегда выделяет память:


auto a1 = [0, 1, 2];auto a2 = [3, 4, 5];auto a3 = a1 ~ a2;

У ассоциативных массивов в D своя стратегия выделения памяти, но можно ожидать, что они будут выделять память при добавлении элементов и при их удалении. Также они реализуют два свойства: keys и values, которые выделяют память под массив и заполняют его копиями соответствующих элементов. Когда ассоциативный массив нужно изменять во время того, как вы по нему итерируете, или если нужно отсортировать элементы или ещё каким-то образом обработать их в отрыве от массива, эти свойства то что доктор прописал. В других случаях это лишь лишнее выделение памяти, чрезмерно нагружающее GC.


Когда сборка мусора всё-таки запускается, время, которое она займёт, будет зависеть от объёма сканируемой памяти. Чем меньше, тем лучше. Никогда не будет лишним избегать ненужных выделений памяти, и это ещё один хороший способ минимизировать влияние сборщика мусора на производительность. Как раз для этого у ассоциативных массивов в D есть три свойства: byKey, byValue и byKeyValue. Каждое из них возвращает диапазон, который можно итерировать ленивым образом. Они не выделяют память, поскольку напрямую обращаются к элементам массива, поэтому не следует его изменять во время итерирования. Более подробно о диапазонах можно прочитать в главах Ranges и More Range из книги Али Чехрели (Ali ehreli) Programming in D.


Замыкания делегаты или функции, которые должны нести в себе указатель на фрейм стека также выделяют память. Последняя возможность языка, упомянутая на страничке Garbage Collection выражение assert. Если проверка проваливается, выражение assert выделяет память, чтобы породить AssertError, которое является частью иерархии исключений языка D, основанной на классах (в будущих статьях мы рассмотрим, как классы взаимодействуют с GC).


И наконец, есть Phobos стандартная библиотека D. Когда-то давным-давно большая часть Phobosа была реализована без особой заботы о выделении памяти через GC, отчего его трудно было использовать в ситуациях, когда это было нежелательно. Однако потом было приложено много усилий, чтобы сделать его более сдержанным в обращении с GC. Некоторые функции были переделаны, чтобы они могли работать с ленивыми диапазонами, другие были переписаны, чтобы они принимали буфер, а некоторые были переработаны, чтобы избежать лишних выделений памяти внутри себя. В результате стандартная библиотека стала гораздо более пригодной для написания кода, свободного от GC (хотя, возможно, ещё остаются места, которые ещё предстоит обновить пулл-реквесты всегда приветствуются).


Теперь, когда мы разобрались с основами работы с GC, в следующей статье в этой серии мы рассмотрим инструменты, предоставляемые язык и компилятор, которые позволяют отключать GC и гарантировать, что критические места программы свободны от GC.


Спасибо Гийому Пьола (Guillaume Piolat) и Стиву Швайхофферу за их помощь в подготовке этой статьи.

Подробнее..

Перевод Life in the Fast Lane

07.07.2020 00:22:55 | Автор: admin
Серия статей о GC
  1. Dont Fear the Reaper
  2. Life in the Fast Lane
  3. Go Your Own Way. Часть первая: Стек
  4. Go Your Own Way. Часть первая: Куча

В первой из серии статей о GC я представил сборщик мусора в языке D и возможности языка, которые его используют. Два ключевых момента, которые я пытался донести:


  1. GC запускается только тогда, когда вы запрашиваете выделение памяти. Вопреки расхожему заблуждению, GC языка D не может просто взять и поставить на паузу ваш клон Майнкрафта посреди игрового цикла. Он запускается только когда вы запрашиваете память через него и только тогда, когда это необходимо.


  2. Простые стратегии выделения памяти в стиле C и C++ позволяют уменьшить нагрузку на GC. Не выделяйте память внутри циклов вместо этого как можно больше ресурсов выделяйте заранее или используйте стек. Сведите к минимуму общее число выделений памяти через GC. Эти стратегии работают благодаря пункту 1. Разработчик может диктовать, когда допустимо запустить сборку мусора, грамотно используя выделение памяти из кучи, управляемой GC.



Стратегии из пункта 2 подходят для кода, который программист пишет сам, но они не особенно помогут, когда речь идёт о сторонних библиотеках. В этих случаях при помощи механизмов языка D и его рантайма можно гарантировать, что в критических местах кода никакого выделения памяти не произойдёт. Также есть параметры командной строки, которые помогают убедиться, что GC не встанет на вашем пути.


Давайте представим, что вы пишете программу на D и по тем или иным причинам решили полностью исключить сборку мусора. У вас есть два очевидных решения.


Таблетка от жадности


Первое решение вызвать GC.disable при запуске программы. Выделение памяти через GC всё ещё будет работать, но сборка мусора остановится. Вся сборка мусора, включая ту, что могла произойти в других потоках.


void main() {    import core.memory;    import std.stdio;    GC.disable;    writeln("Goodbye, GC!");}

Вывод:


Goodbye, GC!

Преимущество этого способа в том, что все возможности языка, использующие GC, продолжат работать, как и ожидалось. Но если учесть, что будет происходить выделение памяти без всякой очистки, то по здравому размышлению вы поймёте, что такое решение может выйти вам боком. Если всё время бесконтрольно выделять память, то рано или поздно какое-то из звеньев цепи сдаст. Из документации:


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

Насколько это плохо, зависит от конкретного случая. Если для вас такое ограничение приемлемо, то есть ещё кое-какие инструменты, которые помогут держать всё под контролем. Вы можете по необходимости вызывать GC.enable и GC.collect. Такая стратегия позволяет контролировать циклы освобождения ресурсов лучше, чем простые техники из C и C++.


Антимусоросборочная стена


Когда запуск сборщика мусора абсолютно неприемлем, вы можете обратиться к атрибуту @nogc. Повесь его на main, и минует тебя сборка мусора.


@nogcvoid main() { ... }

Это окончательное решение вопроса GC. Атрибут @nogc, применённый к main, гарантирует, что сборщик мусора не запустится никогда и нигде на всём протяжении стека вызовов. Больше никаких подводных камней если это необходимо для дальнейшей корректной работы программы.


Не первый взгляд такое решение кажется гораздо лучшим, чем GC.disable. Давайте попробуем.


@nogcvoid main() {    import std.stdio;    writeln("GC be gone!");}

На этот раз мы не продвинемся дальше компиляции:


Error: @nogc function 'D main' cannot call non-@nogc function 'std.stdio.writeln!string.writeln'(Ошибка: @nogc-функция 'D main' не может вызвать не-@nogcфункцию 'std.stdio.writeln!string.writeln')

Сила атрибута @nogc в том, что компилятор не позволяет его обойти. Он работает очень прямолинейно. Если функция обозначена как @nogc, то любая функция, которую вы вызываете внутри неё, также должна быть обозначена как @nogc. Очевидно, что writeln это требование не выполняет.


И это ещё не всё:


@nogc void main() {    auto ints = new int[](100);}

Компилятор не спустит вам с рук и этого:


Error: cannot use 'new' in @nogc function 'D main'(Ошибка: нельзя использовать 'new' в @nogc-функции 'D main')

Также внутри @nogc-функции нельзя использовать любые возможности языка, которые выделяет память через GC (их мы рассмотрели в предыдущей статье серии). Мир без сборщика мусора. Большое преимущество такого подхода в том, что он гарантирует, что даже сторонний код не может использовать эти возможности и выделять память через GC за вашей спиной. Недостаток же в том, что сторонние библиотеки, разработанные без @nogc, становятся для вас недоступны.


При таком подходе вам придётся прибегнуть к различным обходным путям, чтобы обойтись без несовместимых с @nogc возможностей языка и библиотечных функций, включая некоторые функции из стандартной библиотеки. Некоторые из них тривиальны, другие сложнее, а что-то и вовсе нельзя обойти (мы подробно всё это рассмотрим в будущих статьях). Неочевидный пример одной из таких вещей исключения. Идиоматический способ породить исключение такой:


throw new Exception("Blah");

Из-за того, что здесь есть new, в @nogc-функции так написать нельзя. Чтобы обойти это ограничение, требуется заранее выделить место под все исключения, которые могут быть выброшены, а для этого требуется потом как-то освобождать эту память, из чего проистекают идеи об использовании для механизма исключений подсчёта ссылок или о выделении памяти на стеке Короче говоря, это большой клубок проблем. Сейчас появилось предложение по улучшению D Уолтера Брайта, которое призвано распутать этот клубок и сделать так, чтобы throw new Exception работало без GC, когда это необходимо.


К сожалению, проблема использования исключений в @nogc-коде не решена до сих пор. (прим. пер.)

Справиться с ограничениями @nogc main вполне выполнимая задача, она просто потребует немного мотивации и дисциплины.


Ещё одна вещь, которую стоит отметить: даже @nogc main не исключает GC из программы полностью. D поддерживает статические конструкторы и деструкторы. Первые срабатывают перед входом в main, а последние после выхода из неё. Если они есть в коде и не обозначены как @nogc, то, технически, выделение памяти через GC и сборка мусора могут происходить даже в @nogc-программе. Тем не менее, атрибут @nogc, применённый к main, означает, что на протяжение работы main сборка мусора запускаться не будет, так что по сути это то же самое, что не иметь никакого GC.


Добиваемся хорошего результата


Здесь я выскажу мнение. Существует широких спектр программ, которые можно написать на D, не отключая GC на время и не отказываясь от него полностью. Очень много можно добиться, минимизируя выделение памяти через GC и исключая его из горячих точек кода и именно так и следует делать. Я не устаю это повторять, потому что часто неправильно понимают, как происходит сборка мусора в языке D: она может запуститься только когда программист выделяет память через GC и только когда это нужно. Используйте это знание с пользой, выделяя мало, редко и за пределами внутренних циклов.


В тех программах, где действительно нужен полный контроль, возможно, нет необходимости полностью отказываться от GC. Рассудительное использование @nogc и/или API core.memory.GC зачастую позволяет избежать любых проблем с производительностью. Не вешайте атрибут @nogc на main, повесьте его на функции, где точно нужно запретить выделение памяти через GC. Не вызывайте GC.disable в начале программы. Вызывайте её перед критическим местом, а после него вызывайте GC.enable. Сделайте так, чтобы GC собирал мусор в стратегических точках (например, между уровнями игры), при помощи GC.collect.


Как и всегда в оптимизации производительности при разработке ПО, чем полнее вы понимаете, что происходит под капотом, тем лучше. Необдуманное использование API core.memory.GC может заставить GC выполнять лишнюю работу или не оказать никакого эффекта. Для лучшего понимания внутренних процессов вы можете использовать тулчейн D.


В скомпилированную программу (не компилятор!) можно передать параметр рантайма D --DRT-gcopt=profile:1, который поможет вам в тонкой настройке. Вы получите полезную информацию от профилировщика GC, такую как суммарное количество сборок мусора и суммарное время, затраченное на них.


В качестве примера: gcstat.d добавляет двадцать значений в динамический массив целых чисел.


void main() {    import std.stdio;    int[] ints;    foreach(i; 0 .. 20) {        ints ~= i;    }    writeln(ints);}

Компиляция и запуск с параметром профилировщика GC:


dmd gcstat.dgcstat --DRT-gcopt=profile:1[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]        Number of collections:  1        Total GC prep time:  0 milliseconds        Total mark time:  0 milliseconds        Total sweep time:  0 milliseconds        Total page recovery time:  0 milliseconds        Max Pause Time:  0 milliseconds        Grand total GC time:  0 millisecondsGC summary:    1 MB,    1 GC    0 ms, Pauses    0 ms <    0 ms

Отчёт сообщает об одной сборке мусора, которая, по всей вероятности, произошла во время выхода из программы. Рантайм D завершает работу GC на выходе, что (в текущей реализации) обычно вызовет сборку мусора. Это делается главным образом для того, чтобы запустить деструкторы собранных объектов, хотя D и не требует, чтобы деструкторы объектов под контролем GC когда-либо вызывались (это тема для одной из следующих статей).


DMD поддерживает параметр командной строки -vgc, который отобразит каждое выделение памяти через GC в вашей программе включая те, что спрятаны за возможностями языка, такими как оператор присоединения ~=.


В качестве примера: взгляните на inner.d.


void printInts(int[] delegate() dg){    import std.stdio;    foreach(i; dg()) writeln(i);} void main() {    int[] ints;    auto makeInts() {        foreach(i; 0 .. 20) {            ints ~= i;        }        return ints;    }    printInts(&makeInts);}

Здесь makeInts внутренняя функция. Указатель на нестатическую внутреннюю функцию является не указателем на функцию, а делегатом, то есть парным указателем на функцию/контекст (если внутренняя функция обозначена как static, то вместо типа delegate вы получаете тип function). В этом конкретном случае делегат обращается к переменной в родительской области видимости.


Вот вывод компилятора с опцией -vgc:


dmd -vgc inner.dinner.d(11): vgc: operator ~= may cause GC allocationinner.d(7): vgc: using closure causes GC allocation(inner.d(11): vgc: оператор ~= может вызвать выделение памяти через GC)(inner.d(7): vgc: использование замыкания может вызвать выделение памяти через GC)

Здесь мы видим, что нужно выделить память, чтобы делегат мог нести в себе состояние ints, что делает его замыканием (которое не является отдельным типом тип по-прежнему delegate). Переместите объявление ints внутрь области видимости makeInts и скомпилируйте снова. Вы увидите, что выделение памяти из-за замыкания пропало. Ещё лучше изменить объявление printInts таким образом:


void printInts(scope int[] delegate() dg)

Добавление scope к параметру функции гарантирует, что никакие ссылки в этом параметре не могут выйти за пределы функции. Иначе говоря, становится невозможным присвоить делегат dg глобальной переменной или вернуть его из функции. В результате отпадает необходимость создавать замыкание, так что никакого выделения памяти не будет. См. в документации об указателях на функции, делегатах и замыканиях, а также о классах хранения параметров функций.


Резюме


Учитывая, что GC языка D сильно отличается от того, что есть в языках вроде Java и C#, его производительность будет иметь другую характеристику. Кроме того, программы на D как правило производят гораздо меньше мусора, чем программы на языках вроде Java, где почти все типы имеет ссылочную семантику. Полезно это понимать, приступая к своему первому проекту на D. Стратегии, которые применяют опытные программисты на Java, чтобы уменьшить влияние GC на производительность, здесь вряд ли применимы.


Хотя определённо существует программы, где паузы на сборку мусора абсолютно неприемлемы, их, пожалуй, меньшинство. В большинстве проектов на D можно и нужно начинать с простых приёмов из пункта 2 в начале статьи, а затем адаптировать код к использованию @nogc и core.memory.GC там, где требуется производительность. Параметры командной строки, представленные в этой статье, помогут найти места, где это может быть необходимо.


Чем больше времени проходит, тем проще становится управлять сборщиком мусора в программах на D. Идёт организованная работа над тем, чтобы сделать Phobos стандартную библиотеку D как можно более совместимой с @nogc. Улучшения языка, такие как предложение Уолтера о выделении памяти под исключения, должны значительно ускорить этот процесс.


В будущих статьях мы рассмотрим, как выделять память, не прибегая к GC, и использовать её параллельно с памятью из GC, чем заменить недоступные в @nogc-коде возможности языка и многое другому.


Спасибо Владимиру Пантелееву, Гильяму Пьола (Guillaume Piolat) и Стивену Швайхофферу (Steven Schveighoffer) за ценные отзывы о черновике этой статьи.

Подробнее..

Категории

Последние комментарии

© 2006-2020, personeltest.ru