Некоторые программы используют системный вызов vmsplice
для более быстрого перемещения данных через pipe. Франческо уже провел детальный анализ использования vmsplice для ускорения работы. Однако, во время экспериментов, я заметил, что при отсутствии vmsplice pipe в Linux работают медленнее, чем я ожидал. Поскольку vmsplice
нельзя использовать всегда, я захотел понять, почему так происходит и можно ли ускорить pipe.
Я пишу программу для сверхбыстрого кодирования/декодирования азбуки Морзе и использую pipe для передачи данных.
Первое, что приходит в голову для исследования это Fizz Buzz throughput competition at the Code Golf StackExchange. Существуют два типа решений:
-
первые достигают скорости до нескольких гигабайт в секунду, например, решение Neil, достигает 8,4 GiB/s;
-
вторые значительно превосходят результаты первых, начиная с решения 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 на ядро).
Теперь, чтобы понять, что происходит, нам нужно ответить на следующие вопросы:
-
Насколько быстро мы можем записывать данные в идеальных условиях?
-
Насколько быстро мы можем на самом деле записывать данные в pipe?
-
Как помогает
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 раз медленнее записи в буфер! Как может системный вызов, по сути записывающий в буфер ядра быть настолько медленным? И нет, смена контекста не занимает так много времени.
Пришло время заняться профилированием этой программы.
Имейте в виду, что __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
:
Как и в случае с 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
. Их часто описывают как способ избегания копирования данных между буферами, и это верно, но, что наиболее важно, они полностью обходят консервативный код ядра с его обширными процедурами и скалярным кодом.
Автор: MrPizzly