Pull to refresh
74.33
Beget
+7 (800) 700-06-08

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

Level of difficultyMedium
Reading time9 min
Views11K
Original author: Quentin Santos

Некоторые программы используют системный вызов 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'\0'; 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 не используется. Похоже, что минимизация количества системных вызовов не всегда наиболее корректный подход.

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

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

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

Tags:
Hubs:
+40
Comments11

Articles

Information

Website
beget.com
Registered
Founded
Employees
101–200 employees
Location
Россия