Linux Pipes – медленные

в 16:06, , рубрики: linux pipes, Rust, vmsplice, профилирование производительности

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

Я пишу программу для сверхбыстрого кодирования/декодирования азбуки Морзе и использую pipe для передачи данных.

Первое, что приходит в голову для исследования это Fizz Buzz throughput competition at the Code Golf StackExchange. Существуют два типа решений:

  1. первые достигают скорости до нескольких гигабайт в секунду, например, решение Neil, достигает 8,4 GiB/s;

  2. вторые значительно превосходят результаты первых, начиная с решения Timo Kluck достигающего 15,5 GiB/s, заканчивая решениями ais523 достигающим 60,8 GiB/s и David Frank достигающим 208,3 GiB/s при использовании нескольких ядер.

Разница между первой и второй группой заключается в том, что вторая использует vmsplice, а первая — нет. Но как vmsplice может обеспечить такой значительный прирост производительности? Моя интуиция подсказывает, что vmsplice позволяет избежать копирования данных в пространство ядра и обратно. Ведь не может же быть копирование данных медленнее, чем их генерация, верно? Даже если предположить, что оно не быстрее, и что необходимо копировать данные дважды, чтобы передать их через pipe, можно было бы ожидать прироста скорости максимум в 3 раза. Но на деле мы видим прирост в 7 раз, даже если рассматривать решения, использующие одно ядро.

Как будто бы я что‑то упускаю и я хочу понять, что именно.

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

$ ./fizzbuzz | pv >/dev/null
96.4GiB 0:00:01 [96.4GiB/s]

С решением Дэвида результаты достигают 277 GB/s при использовании 7 ядер (40 GB/s на ядро).

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

  1. Насколько быстро мы можем записывать данные в идеальных условиях?

  2. Насколько быстро мы можем на самом деле записывать данные в pipe?

  3. Как помогает vmsplice?

Запись данных в идеальном мире

Для начала, давайте рассмотрим следующую программу, которая просто копирует данные без выполнения системных вызовов. Я использую std::hint::black_box, чтобы не дать компилятору заметить, что результат не используется. В противном случае компилятор оптимизировал бы программу до ничего.

fn main() {
    let dst = [0u8; 1 << 15];
    let src = [0u8; 1 << 15];
    let mut copied = 0;
    while copied < (1000 << 30) {
        std::hint::black_box(dst).copy_from_slice(&src);
        copied += src.len();
    }
}

На моей системе она выполняется со скоростью 167 GB/s. Это соответствует скорости записи в кэш L1 для моего процессора.

При профилировании с помощью ftrace мы видим, что 99,9% времени тратится на функцию __memset_avx512_unaligned_erms, которая вызывается непосредственно из main и не вызывает другие функции. Flame Graph практически плоский. Если вы не хотите использовать полноценный профайлер, можете просто воспользоваться gdb и нажать Ctrl+C в случайное время:

$ cargo build --release
$ gdb target/release/copy 
…
(gdb) run
…
^C (hitting Ctrl+C)
Program received signal SIGINT, Interrupt.
__memset_avx512_unaligned_erms () at ../sysdeps/x86_64/multiarch/memset-vec-unaligned-erms.S:236
…
=> 0x00007ffff7f15dba    f3 aa    rep stos %al,%es:(%rdi)

В любом случае, обратите внимание, что используется AVX-512. Упоминание memset в названии может быть неожиданным — это связано с тем, что часть логики общая с memcpy. Реализация находится в общем файле, посвященном SIMD‑векторизации, который поддерживает SSE, AVX2 и AVX-512. В нашем случае используется специализация для AVX-512.

Заметьте, что реализация memcpy в glibc использует vm_copy для копирования страниц напрямую в системах на основе Mach (в основном продукты Apple), которые используют функцию ядра для прямого копирования страниц.

Тем не менее, AVX-512 является довольно нишевой технологией. Согласно данным об оборудовании пользователей Steam, только около 12% пользователей Steam обладают процессорами с поддержкой AVX-512. Intel добавляла поддержку AVX-512 для потребительских процессоров только в 11-м поколении, а теперь оставляет его только для серверов. Процессоры AMD поддерживают AVX-512 с серии Ryzen 7000 (Zen 4).

Итак, я протестировал эту же программу, отключив AVX-512. Для этого я использовал опцию ядра Linux clearcpuid=304. Я смог проверить, что использовалась функция __memset_avx2_unaligned_erms, воспользовавшись трюком с gdb и Ctrl+C. Затем я сделал то же самое, чтобы отключить AVX2 с помощью clearcpuid=304,avx2,avx, что заставило её использовать функцию __memset_sse2_unaligned_erms.

Хотя SSE2 всегда доступен на x86–64, я также отключил бит cpuid для SSE2 и SSE, чтобы посмотреть, сможет ли это заставить glibc использовать скалярные регистры для копирования данных. В результате я сразу получил kernel panic. Увы.

При использовании AVX2 пропускная способность составила… 167 GB/s. При использовании только SSE2 пропускная способность осталась… всё той же — 167 GB/s. В определенной степени это имеет смысл: даже SSE2 вполне достаточно, чтобы полностью использовать шину и покрыть пропускную способность кэша L1. Использование больших регистров помогает только при выполнении ALU‑операций.

Вывод из этого эксперимента таков: пока используется векторизация, результат должен достигать 167 GB/s.

Запись данных в pipe

Что ж, давайте посмотрим, что произойдет при записи в pipe вместо памяти пространства пользователя:

use std::io::Write;
use std::os::fd::FromRawFd;
fn main() {
    let vec = vec![b''; 1 << 15];
    let mut total_written = 0;
    let mut stdout = unsafe { std::fs::File::from_raw_fd(1) };
    while let Ok(n) = stdout.write(&vec) {
        total_written += n;
        if total_written >= (100 << 30) {
            break;
        }
    }
}

Для измерения пропускной способности будем использовать:

cargo run --release | pv >/dev/null

На моем устройстве результат достигает 17 GB/s. Это в 10 раз медленнее записи в буфер! Как может системный вызов, по сути записывающий в буфер ядра быть настолько медленным? И нет, смена контекста не занимает так много времени.

Пришло время заняться профилированием этой программы.

В оригинальной статье Flame Graph интерактивный.

В оригинальной статье Flame Graph интерактивный.

Имейте в виду, что __GI___libc_write это glibc обертка, выполняющая системный вызов. Все, начиная с неё выполняется в пространстве пользователя, все до неё — в ядре.

Как и ожидалось, основную часть времени занимает вызов write. В частности, 95% времени уходит на pipe_write. Внутри самой функции 36% общего времени уходит на __alloc_pages, предоставляющий новые страницы памяти для pipe. Мы не можем просто заново использовать одни и те же страницы, поскольку pv перемещает их, используя splice в /dev/null, поглощая их в процессе.

Далее идут __mutex_lock.constprop.0, занимающий 25% времени, и _raw_spin_lock_irq, на который уходит 5%. Они блокируют запись в pipe.

Получается, что на копирование данных copy_user_enhanced_fast_string тратит только 20% времени. Но даже имея лишь 20% процессорного времени, мы могли бы ожидать производительность в 167 GB/s * 20% = 33 GB/s. Это значит, что даже сама по себе эта функция в 2 раза медленнее __memset_avx512_unaligned_erms, использованной в программе, писавшей в память пространства пользователя.

Но что же делает copy_user_enhanced_fast_string настолько медленной? Нам нужно копнуть глубже. Пришло время дизассемблировать мое ядро Linux и посмотреть на устройство этой функции.

$ grep -w copy_user_enhanced_fast_string /usr/lib/debug/boot/System.map-6.1.0-18-amd64 
ffffffff819d3d90 T copy_user_enhanced_fast_string
$ objdump -d --start-address=0xffffffff819d3d90 vmlinuz | less   
    
vmlinuz:     file format elf64-x86-64


Disassembly of section .text:

ffffffff819d3d90 <.text+0x9d3d90>:

ffffffff819d3d90:       90                      nop
ffffffff819d3d91:       90                      nop
ffffffff819d3d92:       90                      nop
ffffffff819d3d93:       83 fa 40                cmp    $0x40,%edx
ffffffff819d3d96:       72 48                   jb     0xffffffff819d3de0
ffffffff819d3d98:       89 d1                   mov    %edx,%ecx
ffffffff819d3d9a:       f3 a4                   rep movsb %ds:(%rsi),%es:(%rdi)
ffffffff819d3d9c:       31 c0                   xor    %eax,%eax
ffffffff819d3d9e:       90                      nop
ffffffff819d3d9f:       90                      nop
ffffffff819d3da0:       90                      nop
ffffffff819d3da1:       e9 9a dd 42 00          jmp    0xffffffff81e01b40
...
ffffffff81e01b40:       c3                      ret

Инструкции NOP в начале и конце функции позволяют ftrace вставить инструкции для трассировки при необходимости. Это позволяет собирать данные о производительности определенных функций ядра, не замедляя остальные. Пайплайн декодера процессора позаботится о NOP заранее, так что влияние на производительность должно быть минимальным (если не считать использование ими кэша L1i).

Чего я не понимаю, так это почему используется JMP, а не просто RET.

В любом случае, проверка CMP и прыжок JB покрывают случаи использования буферов менее 64 байт, перемещаясь к другой функции, копирующей 8 байт за раз в 64-битные регистры и затем 1 байт за раз в 8-битный регистр за 2 цикла. Копирование больших буферов происходит за счет инструкции REP MOV. Этот код явно не векторизован.

На самом деле, эта функция реализована не на C, а напрямую в Assembly! Это значит, что нам не нужно смотреть на результат компиляции — мы можем сразу перейти к исходному коду. И это не пропущенная оптимизация на этапе компиляции, он был так написан изначально.

Но является ли отсутствие векторной инструкции единственной причиной того, что copy_user_enhanced_fast_string в 2 раза медленнее __memset_avx512_unaligned_erms? Для проверки я адаптировал первоначальную программу на Rust с использованием REP MOVS:

use std::arch::asm;

fn main() {
    let src = [0u8; 1 << 15];
    let mut dst = [0u8; 1 << 15];
    let mut copied = 0;
    while copied < (1000u64 << 30) {
        unsafe {
            asm!(
                "rep movsb",
                inout("rsi") src.as_ptr() => _,
                inout("rdi") dst.as_mut_ptr() => _,
                inout("ecx") 1 << 15 => _,
            );
        }
        copied += 1 << 15;
    }
}

Пропускная способность составляет 80 GB/s. Это и есть то самое замедление в 2 раза, которое мы наблюдали в функции ядра!

Теперь мы знаем, что ядро Linux не использует SIMD для копирования памяти и это делает copy_user_enhanced_fast_string в 2 раза медленнее, чем она могла бы быть.

Но почему? На Stack Overflow, Peter Cordes объясняет, что использование инструкций SSE/AVX в большинстве случаев не стоит того из‑за затрат на сохранение в восстановление контекста SIMD.

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

vmsplice спешит на помощь

Теперь у нас есть верхняя (167 GB/s для записи в память 1 раз) и нижняя границы (17 GB/s при использовании write в pipe). Давайте детально посмотрим, что делает vmsplice. Он снижает затраты на использование pipe»ов за счет перемещения буферов из пространства пользователя в ядро без копирования.

Чтобы понять, как это работает, прочтите эту великолепную статью от Francesco. Мы будем использовать программу ./write из статьи в качестве минимального примера использования vmsplice. Эта программа записывает бесконечное количество 'X'. Это упростит профилирование, поскольку она не будет тратить время на вычисление Fizz Buzz или что‑либо еще.

На практике ./write достигает 210 GB/s, что значительно выше нашей верхней границы, но в данном случае программа работает немного нечестно, используя одни и те же буферы для передачи в vmsplice. Для чего‑либо кроме постоянного потока байт, нам потребуется заполнять буферы новыми данными, где мы и упремся в нашу верхнюю границу. Как бы то ни было, нас интересует только то, что делает vmsplice:

В оригинальной статье Flame Graph интерактивный.

В оригинальной статье Flame Graph интерактивный.

Как и в случае с write, мы тратим значительное количество времени (37%) на __mutex_lock.constprop.0. Но теперь нет _alloc_pages и _raw_spin_lock_irq. А также вместо copy_user_enhanced_fast_string мы видим add_to_pipe, import_iovec и iov_iter_get_pages2. Из этого мы можем увидеть как vmsplice обходит затратные участки системного вызова write.

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

Linux Pipes – медленные - 3

Подводя итоги

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

В принципе, мы могли бы перемещать данные со скоростью 167 GB/s, но нам нужно избежать затрат на блокировки буфера и на сохранение и восстановление контекста SIMD. Именно это делают splice и vmsplice. Их часто описывают как способ избегания копирования данных между буферами, и это верно, но, что наиболее важно, они полностью обходят консервативный код ядра с его обширными процедурами и скалярным кодом.

Автор: MrPizzly

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js