Все, кто хоть раз касался разработки I/O интенсивных приложений, наверное, задумывался о повышении их производительности. Особенно когда у проекта много внешних хранилищ и они находятся по всему миру. Давайте разберем какие есть инструменты с их плюсами и минусами, и как их лучше использовать на примере конкретного проекта, в котором принимал участие Дмитрий Бундин, старший Big Data-разработчик в Grid Dynamics.

Проект был такой: HTTP Server по классической схеме принимает запросы от пользователя, отправляет их на локальный диск, а спустя 10-15 минут собирает один батч и скидывает полученные сырые данные на внешнее хранилище, в облака и различные HDFS. Проблема в том, что из-за большого зоопарка серверов с проектами, всё это стоило довольно больших денег. Поэтому команде нужно было как-то это решить, и первое на что они обратили внимание — что все инстансы (так исторически сложилось) были перманентными.

Перманентные инстансы для трех майнстримных облачных провайдеров стоили около $3 за часовой rate, а preemptible всего $0,3 за часовой rate. Очевидное решение — заменить все permanent на preemptible, но замена привела к проблеме потери данных.

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

В случае с HTTP Server никаких проблем нет: погасили все pthread’ы и выключились. А в случае системы доставки проблемы есть, и довольно значительные, потому что данных накапливается от нескольких килобайт до одного терабайта. А передать по сети терабайт, еще и сжать его по дороге, не удавалось. Тут как раз данные и терялись.

HTTP Server, на котором были запущены доставки, был написан на «плюсах», был тяжеловесным, делал много хардкорной бизнес-логики и потреблял очень много CPU и памяти. Поэтому забирать у него эту память и CPU было крайне нежелательно. Отсюда выросла вторая нога проблемы: уменьшить вероятности потери во время доставки данных во «внешние хранилища» и оптимизировать её.

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

Уменьшение вероятности потери данных

Понятно, что если батч доставка не работает, надо пробовать стримить данные. Например, собирать их в буфер по 8 MB и сливать в сеть storage. Но такой способ имеет несколько недостатков. Прежде всего, это доработка HTTP Server. Как уже говорили, он был тяжеловесный и сложный, поэтому обратились к другому способу. Передачу данных на локальный диск оставили, а батч доставку убрали и начали реализовывать с нуля.

Оптимизация доставки данных

Начнем с самого верхнего уровня — непосредственно с оптимизации Java кода, и закончим уровнем микроархитектуры x86 CPU, и посмотрим какие моменты есть в ядре Linux, которые могут быть полезны. В случае данного проекта от системы доставки требовались три основных функции:

  1. Непрерывный мониторинг локального диска на наличие новых данных и чтение;

  2. Обработка (зашифровать и сжать) после получения данных;

  3. После обработки данных их отправка по сети.

Первый этап. Чтение и мониторинг локального диска.

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

Несмотря на простоту и удобство реализации у этого способа есть недостатки. Если попытаться подстроить интервал, то потеряется его основное преимущество — простота. А еще это затратная операция с точки зрения CPU. Например, есть 10 тысяч файлов, поменялось 10, их определили, а остальные 9990 проскипали, чтобы убедиться, что они не изменились.

Поэтому команда обратилась к альтернативному способу определения уведомлений — их непосредственному получению от файловой системы. Файловая система и ОС дружат. Операционка знает про все операции, которые были сделаны из user space в файловой системе, потому что они делаются через специальные системные вызовы. Соответственно, операционка может поделиться ими с приложением. В случае Java есть набор из интерфейсов WatchService, WatchKey, WatchEvent, которые умеют с этим знанием работать и предоставляют их Java-разработчикам.

Java-приложение опрашивает инстанс WatchService (прокси между Java-приложением и операционкой) на наличие изменений. Как только операционка отвечает WatchService, что появились какие-то события, WatchService отвечает Java-приложению с помощью WatchKey, который уже содержит описание этих событий.

Несмотря на удобство этого способа, у него тоже есть недостатки. Например, он представляет небольшое количество эвентов. Через WatchService можно отслеживать: ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY и специальный OVERFLOW.

Если с первыми тремя всё понятно, то OVERFLOW показывает, что что-то пошло не так: в ОС или под капотом WatchService, и надо весь state, который отслеживали с помощью этих эвентов, скидывать и пересоздавать заново.

Второй не менее важный недостаток, который никак не лечится — это игнорирование перемещений. Например: переименовав Файл 1 в Файл 2, и получив два уведомления ENTRY_DELETE и ENTRY_CREATE, их нельзя отличить от того, что файл просто удалили и создали новый. Если заглянуть под капот hotspot VM, то там есть специальный идентификатор cookie, который, как раз, используется в ОС Linux на уровне нативного API для отслеживания перемещений.

LinuxWatchService.c

arr [2] = (jint)offsetof(struct inotify_event, cookie);

Этот cookie, несмотря на присутствие под индексом 2, в Java код из JNI-вызова не попадает, потому что данный API должен быть кроссплатформенным, а перемещение далеко не везде есть в качестве нативного API в ОС. Поэтому для кроссплатформенных решений его пропустили и сделать с этим ничего нельзя.

Итого, если нужны максимально гранулярные подмножества эвентов, которые хочется отслеживать с помощью системы нотификации, например, трекать перемещения, лучше использовать альтернативный WatchService способ. На O/S Linux он называется inotify.

Это нативное API, которое Linux предоставляет в виде «сишной» структуры inotify_event. Она содержит идентификатор cookie и три функции, которые нужны для инициализации, подписки и отписки на события.

#include <sys/inotify.h>

struct inotify_event {

int wd;

unit32_t mask;

unit32_t cookie;

unit32_t len;

char name[];

}

int inotify_init(void);

int inotify_add_watch(int fd, const char *pathname, uint32_t mask);

int inotify_rm_watch(int fd, int wd);

Основная фича этого подхода в 12 эвентах: IN_MOVED_FROM и IN_MOVED_TO можно с помощью cookie сматчить друг на друга и трекать перемещение. Но и этот способ не без изъянов. Он не кроссплатформенный и даже не POSIX OS.

POSIX — это стандарт, который предоставляет набор «сишных» хэдерных функций, утилит и соглашений.

Если мы используем только POSIX-функции и соглашения, то наше приложение будет доступно, то есть соберется и запустится на всех POSIX compatible ОС (MacOS и в некотором смысле Linux). Но в случае с inotify приложение не запустится даже на MacOS, у которого есть свой API FSEvents, и нужно будет затачиваться непосредственно под него.

Второй недостаток в том, что для использования Java надо написать на ОС и прокидывать через JNI или ему подобных приблудах, которые позволяют брать inotify из Java-кода.

Чтобы разобраться, какой подход использовать, сравнили простой поллинг и inotify с потреблением CPU на i7-8550U KbL и Linux Kernel 5.3.0. Оказалось, что потребление значительно меньше при небольшом количестве изменяющихся файлов, и почти в 2 раза меньше при изменении всех файлов раз в секунду.

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

После сравнения команда проекта сделала выбор в пользу inotify, поскольку с его помощью они могли полностью контролировать инфраструктуру. Они собирались запускаться только на Linux и делать отладку только на Linux-машинах, а изменения должны были проходить раз в секунду или реже.

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

Определение предыдущей позиции стриминга

Определение предыдущей позиции стриминга требует идентификации файла. Когда вы получаете уведомление, то должны идентифицировать файл для того, чтобы в соответствии с идентификатором сопоставить state, который покажет сколько байт уже застримили. Для этого есть несколько способов.

Идентификация файла: абсолютный путь

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

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

Идентификация файла: хэш первых n байт

Другой более продвинутый способ — это хэш нескольких первых записей: #/var/log/file.log. У этого общего способа тоже были недостатки. Если есть два файла с одинаковым контентом, а в проекте такие файлы были по историческим причинам, то когда один вымывался в другой, получалось несоответствие state одного файла с другим. Тут возможен data corruption.

Поэтому лучше обратимся к более продвинутому способу.

Идентификация файла: POSIX file serial number

Это такой идентификатор, который гарантированно единственный в пределах одной файловой системы. POSIX предоставляет набор функций, которые можно использовать для его определения: fstat(), lstat(), and stat().

struct stat {
…
ino_t st_ino;
…
}

В Java-приложении эти функции напрямую использовать не надо. У Java есть свой утилитный метод getAttributes, который по пути к файлу может определить этот идентификатор.

package java.nio.file;
public final class Files {
public static Object getAttributes(Path path, String attribute, LinkOption… options)
}

Надо только указать string-значение этого атрибута:

Long inodeNumber = (Long) Files.getAttributes(path, “unix:ino”);

Где «unix:ino» — это получение innode number.

Под капотом нет ничего интересного — обычный вызов stat, который документирован в POSIX.

UnixNativeDispatcher.c

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

Представьте, что у вас есть два файла. Запись производится в Файл 1, вы получаете уведомление о том, что произведена запись. Начинаете его обрабатывать, считаете его inode, используя getAttributes. Посчитали inode, дальше должны по этой inode определить stat, сколько байт уже застримили. В этот момент между вычислением inode и началом чтения данных происходит какой-то процесс (как правило legacy-ротатор), берет произвольно Файл 2 и вымывает в Файл 1. В итоге вы открываете Файл 2, думая, что открыли Файл 1, потому что inode у него от Файла 1.

Такого рода Race Condition приводят к data corruption, а этого тоже надо избегать. Поэтому был нужен способ, позволяющий определять inode и читать из файла без такого race.

Идентификация файла: Race-Free way

Чтобы понять, что это за способ, обратимся еще раз к стандарту POSIX:

include <sys/stat.h>

int stat(const char *pathname, …);

int fstat(int fd, …);  — файловый дескриптор

Помимо функции stat, которая принимает на вход указатель на char, то есть «сишную» строку, есть fstat, работающий с файловым дескриптором. Поэтому из него еще можно читать и накатывать на него системный вызов read.

Это полностью рабочий способ, который искала команда проекта. Другой вопрос, откуда этот файловый int’овый POSIX’ный дескриптор доставать?

Есть несколько вариантов:

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

package java.io;
public final class FileDescriptor {
privite int fd;
}

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

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

Таким образом для использования fstat требуется файловый дескриптор, который нужно где-то доставать, но опять же придется писать нативный код, потому что fstat в стандартной поставке JDK не представлен. Тут тоже есть некоторые сложности с реализацией.

Идентификация файла: перфоманс

Анализируем перфоманс всех stat, fstat и чтение нескольких первых байт для идентификации: горячий dentry cache и page cache. Для замеров в проекте использовали язык программирования C и x86intrin.h. Чтение Core Time-Stamp Counter:

#include <x86intrin.h>
unisigned long long __rdtsc(void);

Первый intrin — это функция rdtsc, которая читает специальный Core Time-Stamp Counter, локальный для каждого CPU ядра. Он инкрементируется с постоянной скоростью. Эта имплементация с постоянной скоростью не зависит от текущей тактовой частоты процессора и состояния power management (Intel System Programming Manual/17.17).

Например, если зайти в kernel space, сделать системный вызов, начать висеть на input-output, ядро ОС поместит CPU и ядро в состояние halted, то есть процессор не будет выполнять инструкции, а будет ждать прерывания, когда ядро вернет его в нормальное состояние. При этом Time-Stamp Counter продолжит инкрементироваться с той же скоростью, с которой и инкрементировался в обычном состоянии в user space.

Сброс L1/L2/LLc/… по адресу А:

#include <immintrin.h>
void _mm_clflush(void const *A);

Второй intrin — это clflush, который нужен для сброса кэша во всей CPU кэш-иерархии по заданному адресу. С его помощью можно сделать бенчмарк уже непосредственно чтение первых нескольких байт, потому что highly likely будет в таком состоянии, что данные в CPU-кэше будут не представлены, а в Kernel кэшах и page-кэшах они будут лежать. Итого, мы получаем такой результат сравнения stat и fstat.

Перфоманс: stat vs fstat

Кажется, что fstat эффективней на 40%, но тут есть несколько нюансов. Во-первых, stat порядка 40% своего выполнения использует, чтобы определить, есть ли такой файл, прочекать его permission, есть ли у текущего пользователя права на вычисление атрибутов этого файла, и, если их нет, то системный вызов должен вернуть -1, потому что такая операция не валидна.

У файлового дескриптора все эти чеки уже выполнены, какой-то процесс уже сделал open, получил от файлового дескриптора проверки и look up уже были проделаны. Это не совсем честное сравнение, нужно сравнивать stat и open fstat. А тут ситуация уже не такая магическая.

Перфоманс: stat vs open fstat

Open fstat — это пара системных вызовов, которая проигрывает stat больше, чем в 2 раза, потому что это не один системный вызов, а два, а каждый системный вызов сопровождается оверхедом на транзакцию из user space в Kernel space и обратно.

Теперь сравним, что по чтению первых несколько байт, в частности, это первая страница.

Перфоманс: pread vs open fstat, чистый кэш

Тут вообще все плохо. Даже open fstat проигрывает у чтения данных из Kernel space в user space почти в два раза, и это даже без вычисления хэша.

Сравнив все способы, для проекта выбрали fstat, потому что он более гибкий и функциональный для идентификации. Команда стала использовать file serial number для state количества отправленных байт.

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

Чтение новых данных

В соответствии с принятыми требованиями чтение в проекте должно было быть эффективным с точки зрения пропускной способности и on-heap. 

GZIP до JDK11 был строго heap’овый, в JDK11 появился GZIP off-heap’овый DirectByteBuffer и коннекторы, тоже heap’овые из коробки. Поэтому команда хотела получить данные именно в byte[], а не в DirectByteBuffer.

В Java есть три способа, как прочитать из файла.

Zero-copy file

Zero-copy file используется для перекачки данных из файла куда-то еще (WritableByteChannel). Это может быть сокет или юниксовый pipe. Под капотом у этого метода системный вызов sendfile, отвечающий за перекачку данных между файловыми дескрипторами.

FileChannelImpl.c 

Это очень эффективный способ для непосредственной перекачки. Если нужно закинуть данные из файла в сокет, их не надо заносить в user space, все делается на уровне ядра ОС. Поэтому метод максимально производителен, но в рамках проекта нужно было делать пользовательскую обработку (шифрование, сжатие), то есть данные были нужны именно в user space. Поэтому, несмотря на эффективность от него отказались.

FileChannel.read

Этот способ представлен методом read, принимающим на вход ByteBuffer и позицию, откуда надо читать. Схема API:

package java.nio.channels;
public abstract class FileChannel {
public abstract int read(ByteBuffer dst, long position)
}

Под капотом у него классический системный вызов pread для чтения по позиции.

FileDispatcherImpl.c

Между вызовом Java-метода read и попаданием в нативную реализацию выполняется ряд проверок, например, является ли инстанс ByteBuffer, в который мы читаем, Direct ByteBuffer.

IOUtil.java

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

java.io.InputStream

Этот способ умеет читать в byte[]. Схема API:

package java.io
public abstract class InputStream implements Closeable {
public int read(byte[] dst, int off, int len)
}

Но для проекта тоже не подходит, потому что использует временные буферы. В зависимости от размера byte[], это стек-аллоцированный буфер, либо heap-аллоцированный, что еще хуже, потому что на каждое чтение делается malloc и потом указатель, который malloc вернул, еще и лагает.

io_util.c

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

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

Критическая секция JNI

Этот метод представлен двумя функциями из JNI API:

GetPrimitiveArrayCritical отвечает за возврат разработчику сырого указателя на какой-то регион heap, который byte[] соответствует, его передают в эту функцию.

ReleasePrimitiveArrayCritical используется для уведомления garbage collector о том, что больше не используются сырые указатели на сырые регионы heap. Garbage collector имеет право выполнять все обычные действия, которые он выполняет с heap (heap compaction, непосредственный garbage collect и т.д.).

void * GetPrimitiveArrayCritical(jarray buf, …);
void ReleasePrimitiveArrayCritical(jarray buf, void * carray, …);

Уведомление garbage collector о том, что используется сырой указатель, выполняется с помощью функции lock_gc_or_pin_object, которая проверяет, имеется ли у garbage collector поддержка object_pinning.

jni.cpp

jni_GetPrimitiveArrayCritical(...) {
oop a = lock_gc_or_pin_object(...);
…
}

Если она имеется, то пинним этот объект, говорим garbage collector его не трогать, а все остальные можно смывать, компактить и т.д. Если такой поддержки нет, то приходится полностью выключать garbage collector, потому что если он не выключится и будет коллектить данные, покарактится heap и повезет, если мы упадем в корку, и не повезет, если получим какую-то магию в рантайме.

В итоге в стандартной поставке JDK, object_pinning поддерживается только у коллектора Shenandoah. Все остальные при входе в критическую секцию будут благополучно выключаться.

Теперь перейдем к реализации critical read метода. Это обычный нативный метод из Java, который принимает на вход файловый дескриптор:

package com.company;
public class PosixUtils {
public static native int read(int fd, byte buf[]);
}

Файловый дескриптор передаем непосредственно в нативную реализацию:

Две функции GetPrimitiveArrayCritical и ReleasePrimitiveArrayCritical будут содержать системный вызов read по этому файловому дескриптору, который передает данные в сырой указатель Region heap.

Остается сравнить насколько он быстрее обычных InputStream и FileChannel, которые читают в хиповый ByteBuffer. Команда проверяла это на горячих page кэшах, потому что это соответствовало их рантайм-кейсу. Получилось значительно быстрее.

В итоге, решили остановиться на critical секции для чтения данных из файла из-за 25% прироста скорости и перформанса.

Система стриминга: Файловое I/O

Итак, рассмотрели все кейсы, научились определять изменившиеся файлы, трекать state и читать данные. Чтобы проверить остались ли какие-то возможности для тюнинга, нужно запустить профайлер (на Linux это стандартный perf) и посмотреть, что происходит в рантайме.

$ sudo perf record --call-graph dwarf -F 9123 -p <app_pid>

Это классический stack trace из ядра, 70% которого забирает специальная функция copy_user_enhanced_fast_string. Она как раз отвечает за копирование данных из page-кэша, которые крутятся в ядре и user space буфер byte[]. Её реализация содержит вот такую инструкцию:

copy_user_64.S

Ссылка на него. Но может возникнуть целый ряд вопросов. Почему не используется AVX? Потому, что в общем случае AVX в ядре использовать нельзя — это декларируется спецификацией AMD64 SysV ABI. 

Каждая инструкция system-call может менять только два регистра — это %rcx и %r11, остальные general-purpose регистры перед входом в Kernel space дампятся на стек, а после выхода из Kernel space с этого стека обратно восстанавливаются.

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

calling.h

Ссылка. System Call и так не быстрая инструкция, поэтому в общем случае AVX не используется, только крайне редко в специальных драйверах.

Будет ли AVX эффективнее rep movsb? Давайте сравним. Для AVX есть классический System.arraycopy, который копирует по кэш линии:

 Для rep movsb есть libc-2.27/memcpy:

При размере памяти 4 KB и более она будет rep movsb вязать для копирования.

Сравнение memcpy и System.arraycopy: Java memcpy

Чтобы memcpy в Java-коде использовать, нужен нативный метод со стандартной критической реализацией.

Сравнение memcpy и System.arraycopy

Используем классический инструмент для бенчмаркинга JVM и код под JVM.

В итоге получим:

На 8 KB и 128 KB различие порядка 10-15%, а вот на больших размерах оно 60-90%, то есть memcpy обгоняет System.arraycopy. Чтобы использовать это в проекте, команда начала разбираться почему memcpy такое быстрое при больших данных.

Non-Temporal Stores

Оказывается, memcpy более умное, чем использование простых rep movsb если память больше 4 KB.

(gdb) disas __memmove_avx_unaligned_erms

<+43>: cmp 0x261b76(%rip),%rdx

<+50>: jae 0x18ec2d

Если копировать память с помощью memcpy, оно делает сравнение на так называемый x86 x86_shared_non_temporal_threshold. На машине, которую использовали в проекте x86_shared_non_temporal_threshold — это 6 MB.  Соответственно, если размер копируемой памяти больше 6 MB, оно переключается на специальную технику Non-Temporal запись.

<+770>: vmovntdq %ymm0,(%rdi)

<+770>: vmovntdq %ymm1,0x20(%rdi)

<+770>: vmovntdq %ymm2,0x40(%rdi)

<+770>: vmovntdq %ymm3,0x60(%rdi)

Non-Temporal Stores: свойства

Ключевая особенность этих Non-Temporal записей заключается в том, что они uncacheable and not write-allocating. Чтобы понять, что это такое, рассмотрим обычную запись, которая есть, например, в Java.

Write Back память: regular stores

Мы делаем запись в поле класса. Основная особенность обычной записи заключается в том, что данные всегда попадают в L1 Data Cache. Даже в случае volatile данные никогда не попадают в память напрямую, а просто расставляются дополнительные барьеры для memory consistency. Для того, чтобы данные из обычной записи попали в кэш, нужно, чтобы линия, куда они записываются, находилась в специальном состоянии Exclusive или Modified. Это значит, что в других ядрах в L1 Data Cache не должно быть этой памяти где-то закэшировано, это состояние shared, когда в нескольких CPU-ядрах содержится область памяти или invalid, когда данные в кэше вообще не представлены.

Если линейка находится в таком неправильном состоянии, CPU стремится это состояние исправить и инициирует Read For Ownership, суть которого в том, чтобы подтянуть данные из верхних уровней иерархии кэшей или памяти в L1 Data Cache.

В отличие от обычной записи Non-Temporal-запись не взаимодействует с кэшом. Данные помещаются в специальный Write-Combining Buffer в CPU-ядре и при его переполнении сливаются в память, не трогая кэш. Если подвести итог, Libc memcpy использует техники: rep movsb от 4KB до 6 MB и Non-Temporal stores от 6 MB.

Перформанс при копировании достигается за счет того, что большие данные не поместятся в кэш, даже в Last level cache, который на мейнстримных архитектурах 8 MB, а на более поздних 6 MB. Понятно, что 32 MB ни в какой кэш не влезут, они будут просто записываться и виктиться. Эту проблему как раз решает Non-Temporal.

Кажется, нашелся эффективный способ скопировать данные из ядра (из Kernel space в user space), но остается третий вопрос — если AVX быстрее, то можно ли его использовать?

Чтение из файла: Non-Temporal stores + mmap

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

Для получения маппленного файла (системный вызов mmap) стали использовать библиотеку one-nio, которая пробрасывала системные вызовы с помощью JNI в Java-код.

В конструкторе MappedFile был mmap:

В методе close munmap, который манмапливает полученный с mmap Region.

В результате замера перформанса при использовании mmap и Non-Temporal копирования на горячем page cache, получается следующее:

Без учета mmap/munmap:

mmap+NT, sec         criticalRead, sec

5.5 GB             0.72             0.72

С учетом mmap/munmap

mmap+NT, sec         criticalRead, sec

5.5 GB             0.82             0.72

Даже без учета mmap/munmap перформанс Non-Temporal копирования с mmap почти такое же, как criticalRead, хотя казалось, что rep movsb гораздо менее эффективный способ, чем Non-Temporal копирование на больших данных. А если учитывать mmap/munmap, то проигрыш по перформансу 25-30%. Non-Temporal копирование вроде быстрое, а перформит хуже. Переход в JNI делается один раз при вызове memcpy, которую пробросили в Java-код, и не дает значительный оверхед. Но если посмотреть под профайлером, то видно, что основную часть (22%) сжирает Page Fault.

Page Fault 

Обращение к памяти в Java и C всегда происходит по специальному логическому адресу. По физическому адресу из user space в память не ходят. Поэтому, чтобы попасть в нужную область памяти, нужно понимать, какой физический адрес соответствует логическому адресу. Для этого CPU при каждом обращении выполняет трансляцию логического адреса на физический.

Для трансляции используется TLB кэш (Translation Lookaside Buffer), который аналогичен обычному memory кэшу, только он кэширует не память, а трансляции: логическому адресу A соответствует физический адрес B. Если нужной трансляции в кэше нет, то CPU обращается к Page-таблице, которая находится в пространстве ядра ОС, она ее майнтейнит, и пытается найти эту трансляцию там. CPU знает, по какому адресу лежит Page Table и ее обходит. Если и там нет нужного адреса (а в проекте его там не было), то CPU кидает специальный exception, который называется Page Fault. Этот exception перехватывается ОС и проставляется соответствие между логическим и физическим адресом.

Команде проекта не нужно было выделять физическую память, она уже была выделена, а Page кэши прогреты, им было нужно проставить соответствие, которое называется Minor Page Fault. Когда физическая память выделяется, оно называется Major Page Fault. Это соответствие как раз и отжирает те 20%, потерянные на первом обращении к mappedRegion.

Если попытаться применить Non-Temporal копирование к региону, который уже запейджфолтин, то как раз видно прирост по перформансу, который был раньше с Non-Temporal копированием.

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

Второй, не менее важный момент: Non-Temporal Stores не кэшируются, а при обработке данных, они всегда будут подсосываться в кэш. Однако, поскольку Non-Temporal Stores не кэшируются, их можно применить в другом месте.

После обработки данных, они поставляются на стриминг, то есть в буфер, который рано или поздно переполнится (4-8 MB в зависимости от конфигурации). Тогда его можно слить в уже обработанные и сжатые данные. Для постановки данных (буфер) на стриминг Non-Temporal — это идеальный выбор, потому что они не кэшируются, и в кэшах они не нужны. В кэшах нужны только данные (буфер), которые мы непосредственно читаем и делаем обработку.

Таким образом, если накатить Non-Temporal, то сильно уменьшатся кэш-промахи и снизятся затраты CPU time на RFO-реквесты, которые будут в связи с этим возникать.

Итоговая схема: система стриминга

В результате на проекте получили следующую схему стриминга:

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

Заключение

По итогу разработки этого приложения на проекте получили:

  • Экономию на инфраструктуре до 40%;

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

  • Уменьшение затрат на стриминг, благодаря оптимизации с JNI Critical и более эффективному использованию CPU-кэшей.

Скорее всего, если вы пишите High Performance приложение, вам придётся так или иначе смотреть под капот платформы, на которой вы пишите. В случае JVM, в зависимости от задачи, придется лезть в hotspot или JIT-компилятор.

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

Видео выступления Дмитрия Бундина на конференции HighLoad ++ 2021:

Конференция для разработчиков выкосонагруженных систем HighLoad++ Foundation 2022 пройдет 17 и 18 марта в Крокус-Экспо. В рамках конференции пройдет профессиональная конференция для Go-разработчиков — GolanfConf 2022

В рамках конференции также будет Open Source трибуна, где 10 лучших авторов смогут рассказать о своем решении. Сейчас идет прием заявок. Присоединяйтесь :)

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


  1. akurilov
    16.12.2021 19:06
    +1

    После "посылает линуксу sikter" читать дальше не стал )


    1. iv_k
      16.12.2021 19:13
      +2

      там еще petrade есть


      1. akurilov
        16.12.2021 19:33
        +1

        Даже не знаю, а что скрывается за petrade?


        1. iv_k
          16.12.2021 19:34
          +2

          могу предположить, что pthread


        1. dmitrii-bu
          16.12.2021 20:43

          pthread


      1. akurilov
        16.12.2021 19:40
        +3

        Ещё нашел!

        Это может быть сокет или юниксовый pay.

        Здесь, вероятно, должен был быть pipe, но получилась новая универсальная платёжная система. Юникойны из файла прямо в Unix Pay! Zero feecopy! Profit!


  1. akurilov
    16.12.2021 19:42

    Такое ощущение, что статья записывалась продажником под устную диктовку инженера


    1. iv_k
      16.12.2021 19:45
      +1

      не, скорее всего с видео выступления записывали.


      1. akurilov
        16.12.2021 19:50

        Ошибка ещё у том, что сигнал посылается не линуксу, а процессу. Видимо, она была ещё в первоисточнике


        1. dmitrii-bu
          16.12.2021 20:40

          В первоисточнике такого не припоминаю, разве что оговорился...


  1. AlexGluck
    17.12.2021 01:43
    +2

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


    1. dmitrii-bu
      17.12.2021 16:02

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

      По поводу опечаток, исправили, спасибо.


  1. Artem_zin
    19.12.2021 16:48

    Сильно удивлён отсутствию упоминаний io_uring, учитывая, что пошли в нативные биндинги и сисколлы в любом случае :/

    Этот Kernel API задизайнен для высокопроизводительного I/O, уменьшения кол-ва копирований и переключений из ядерного пространства в юзерспейс, доступен в Linux Kernel с версии 5.1.

    Или на момент проектирования он ещё не был в ядре и/или у вас старые версии ядра на серверах?