В этой статье я поделюсь с вами долгой историей, которая начинается с op.read()
opendal, а заканчивается неожиданным поворотом. Это путешествие оказалось для меня достаточно поучительным, надеюсь, и для вас оно будет таким же. Я постараюсь максимально точно воссоздать свой опыт и дополню его выводами, которые сделал в процессе.
Все фрагменты кода и скрипты выложены в Xuanwo/when-i-find-rust-is-slow
TL;DR
Если вам срочно нужно знать ответ, сразу перейдите к разделу «Вывод».
Привязка OpenDAL к Python медленнее, чем Python?
OpenDAL — это слой доступа к данным, позволяющий удобно и эффективно получать данные из различных сервисов хранения единым образом. Мы предоставляем для OpenDAL привязку к Python при помощи pyo3.
Однажды @beldathas написал мне в discord о том, что привязка OpenDAL к python медленее, чем python:
import pathlib
import timeit
import opendal
root = pathlib.Path(__file__).parent
op = opendal.Operator("fs", root=str(root))
filename = "lorem_ipsum_150mb.txt"
def read_file_with_opendal() -> bytes:
with op.open(filename, "rb") as fp:
result = fp.read()
return result
def read_file_with_normal() -> bytes:
with open(root / filename, "rb") as fp:
result = fp.read()
return result
if __name__ == "__main__":
print("normal: ", timeit.timeit(read_file_with_normal, number=100))
print("opendal: ", timeit.timeit(read_file_with_opendal, number=100))
Результаты демонстрируют следующее:
(venv) $ python benchmark.py
normal: 4.470868484000675
opendal: 8.993250704006641
Так-так-так. Меня неприятно поразили эти результаты. У меня быстро возникло несколько гипотез:
У Python есть внутренний кэш, способный многократно использовать одну и ту же память?
У Python есть какой-то трюк, ускоряющий чтение файлов?
Лишняя трата ресурсов возникает из-за PyO3?
Я отрефакторил код:
with open("/tmp/file", "rb") as fp:
result = fp.read()
assert len(result) == 64 * 1024 * 1024
import opendal
op = opendal.Operator("fs", root=str("/tmp"))
result = op.read("file")
assert len(result) == 64 * 1024 * 1024
Результаты показывают, что python намного быстрее opendal:
Benchmark 1: python-fs-read/test.py
Time (mean ± σ): 15.9 ms ± 0.7 ms [User: 5.6 ms, System: 10.1 ms]
Range (min … max): 14.9 ms … 21.6 ms 180 runs
Benchmark 2: python-opendal-read/test.py
Time (mean ± σ): 32.9 ms ± 1.3 ms [User: 6.1 ms, System: 26.6 ms]
Range (min … max): 31.4 ms … 42.6 ms 85 runs
Summary
python-fs-read/test.py ran
2.07 ± 0.12 times faster than python-opendal-read/test.py
Привязка OpenDAL к Python кажется более медленной, чем сам Python, и это печальная новость. Давайте разберёмся, в чём причина.
Сервис OpenDAL Fs медленнее, чем Python?
В этом пазле задействовано множество элементов, в том числе rust, opendal, python и pyo3. Попробуем выявить первопричину.
Я реализовал ту же логику через сервис opendal fs на rust:
use std::io::Read;
use opendal::services::Fs;
use opendal::Operator;
fn main() {
let mut cfg = Fs::default();
cfg.root("/tmp");
let op = Operator::new(cfg).unwrap().finish().blocking();
let mut bs = vec![0; 64 * 1024 * 1024];
let mut f = op.reader("file").unwrap();
let mut ts = 0;
loop {
let buf = &mut bs[ts..];
let n = f.read(buf).unwrap();
let n = n as usize;
if n == 0 {
break
}
ts += n;
}
assert_eq!(ts, 64 * 1024 * 1024);
}
Однако результат показывает, что opendal медленнее, чем python, даже если opendal реализован на rust:
Benchmark 1: rust-opendal-fs-read/target/release/test
Time (mean ± σ): 23.8 ms ± 2.0 ms [User: 0.4 ms, System: 23.4 ms]
Range (min … max): 21.8 ms … 34.6 ms 121 runs
Benchmark 2: python-fs-read/test.py
Time (mean ± σ): 15.6 ms ± 0.8 ms [User: 5.5 ms, System: 10.0 ms]
Range (min … max): 14.4 ms … 20.8 ms 166 runs
Summary
python-fs-read/test.py ran
1.52 ± 0.15 times faster than rust-opendal-fs-read/target/release/test
Хотя показателиrust-opendal-fs-read
немного лучше, чем у python-opendal-read
, что демонстрирует потенциал совершенствования привязки и pyo3, всё это не первопричины проблемы. Нам нужно двигаться глубже.
Сервис opendal fs медленнее, чем python.
std fs Rust медленнее, чем Python?
OpenDAL реализует сервис fs через std::fs. Возможно, дополнительные затраты вызваны самим OpenDAL?
Мы реализуем ту же логику через std::fs
rust:
use std::io::Read;
use std::fs::OpenOptions;
fn main() {
let mut bs = vec![0; 64 * 1024 * 1024];
let mut f = OpenOptions::new().read(true).open("/tmp/file").unwrap();
let mut ts = 0;
loop {
let buf = &mut bs[ts..];
let n = f.read(buf).unwrap();
let n = n as usize;
if n == 0 {
break
}
ts += n;
}
assert_eq!(ts, 64 * 1024 * 1024);
}
Но...
Benchmark 1: rust-std-fs-read/target/release/test
Time (mean ± σ): 23.1 ms ± 2.5 ms [User: 0.3 ms, System: 22.8 ms]
Range (min … max): 21.0 ms … 37.6 ms 124 runs
Benchmark 2: python-fs-read/test.py
Time (mean ± σ): 15.2 ms ± 1.1 ms [User: 5.4 ms, System: 9.7 ms]
Range (min … max): 14.3 ms … 21.4 ms 178 runs
Summary
python-fs-read/test.py ran
1.52 ± 0.20 times faster than rust-std-fs-read/target/release/test
Ого, std fs Rust медленнее, чем Python? Как такое возможно?
std fs Rust медленнее, чем Python? Серьёзно?!
Не могу поверить этим результатам: std fs rust неожиданно медленнее, чем Python.
Я разобрался, как использовать strace
для анализа системных вызовов. strace
— это трассировщик системных вызовов для Linux, позволяющий выполнять мониторинг системных вызовов и понимать их процессы.
strace исследует все системные вызовы, диспетчеризируемые программой. Нас должны интересовать аспекты, связанные с/tmp/file
. Каждая строка вывода strace начинается с имени системного вызова, за которым идут входные аргументы и вывод.
Например:
openat(AT_FDCWD, "/tmp/file", O_RDONLY|O_CLOEXEC) = 3
Это значит, что мы вызываем системный вызов openat
, указав аргументы AT_FDCWD
, "/tmp/file"
и O_RDONLY|O_CLOEXEC
. При этом возвращается вывод 3
— дескриптор файла, на который выполняется ссылка в последующем системном вызове.
Отлично, мы в совершенстве освоили strace
. Давайте воспользуемся им!
Результаты strace для rust-std-fs-read
:
> strace ./rust-std-fs-read/target/release/test
...
mmap(NULL, 67112960, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f290dd40000
openat(AT_FDCWD, "/tmp/file", O_RDONLY|O_CLOEXEC) = 3
read(3, "\tP\201A\225\366>\260\270R\365\313\220{E\372\274\6\35\"\353\204\220s\2|7C\205\265\6\263"..., 67108864) = 67108864
read(3, "", 0) = 0
close(3) = 0
munmap(0x7f290dd40000, 67112960) = 0
...
Результаты strace для python-fs-read
:
> strace ./python-fs-read/test.py
...
openat(AT_FDCWD, "/tmp/file", O_RDONLY|O_CLOEXEC) = 3
newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=67108864, ...}, AT_EMPTY_PATH) = 0
ioctl(3, TCGETS, 0x7ffe9f844ac0) = -1 ENOTTY (Inappropriate ioctl for device)
lseek(3, 0, SEEK_CUR) = 0
lseek(3, 0, SEEK_CUR) = 0
newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=67108864, ...}, AT_EMPTY_PATH) = 0
mmap(NULL, 67112960, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f13277ff000
read(3, "\tP\201A\225\366>\260\270R\365\313\220{E\372\274\6\35\"\353\204\220s\2|7C\205\265\6\263"..., 67108865) = 67108864
read(3, "", 1) = 0
close(3) = 0
rt_sigaction(SIGINT, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=SA_RESTORER|SA_ONSTACK, sa_restorer=0x7f132be5c710}, {sa_handler=0x7f132c17ac36, sa_mask=[], sa_flags=SA_RESTORER|SA_ONSTACK, sa_restorer=0x7f132be5c710}, 8) = 0
munmap(0x7f13277ff000, 67112960) = 0
...
Из анализа strace становится понятно, что в python-fs-read
имеется больше системных вызовов, чем в rust-std-fs-read
, и оба они используют mmap
. Так почему же python быстрее, чем rust?
Зачем мы используем здесь mmap?
Изначально я думал, что mmap
нужна только для отображения файлов в память, что позволяет получать доступ к файлам через память. Однако mmap
применяется и иным образом. Часто её используют для распределения больших областей памяти для приложений.
Это можно увидеть в результатах strace:
mmap(NULL, 67112960, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f13277ff000
Этот системный вызов означает следующее:
NULL
: первый аргумент — это начальный адрес области памяти для отображения.NULL
позволяет операционной системе самой выбрать подходящий адрес.67112960
: размер области памяти для отображения. Здесь мы распределяем 64 МиБ + 4 КиБ памяти, дополнительная страница используется для хранения метаданных этой области памяти.PROT_READ|PROT_WRITE
: область памяти доступна для чтения и записи.-
MAP_PRIVATE|MAP_ANONYMOUS
:MAP_PRIVATE
означает, что изменения в этой области памяти не будут видимы для других процессов, выполняющих отображение в ту же область, и что они не переносятся в соответствующий файл (если он есть).MAP_ANONYMOUS
означает, что мы распределяем анонимную память, не связанную с файлом.
-1
: дескриптор файла, отображение которого мы выполняем.-1
означает, что мы не отображаем файл.0
: смещение в файле, с которого нужно выполнять отображение. Здесь использован0
, потому что мы не отображаем файл.
Но я не использую mmap в своём коде.
Системный вызовmmap
диспетчеризируется библиотекойglibc
. Мы используем malloc
для запроса памяти у системы, а в ответ glibc
задействует системные вызовы brk
и mmap
для распределения памяти согласно запрошенному нами размеру. Если запрошенный размер достаточно велик, то glibc
применяет mmap
, которая позволяет устранять проблемы фрагментации памяти.
По умолчанию все программы на Rust, компилируемые под целевую платформу x86_64-unknown-linux-gnu
, используют malloc
, предоставляемую glibc
.
Использует ли Python тот же распределитель памяти, что и Rust?
По умолчанию Python использует pymalloc
— распределитель памяти, оптимизированный под небольшие распределения. Python имеет три диапазона памяти, каждый из который отражает разные стратегии распределения и оптимизирован под разные цели.
pymalloc
имеет следующее поведение:
У Python есть распределитель
pymalloc
, оптимизированный под мелкие объекты (меньше или равные 512 байтам) с коротким временем жизни. Он использует отображения памяти, называемые «аренами» (arena) c фиксированным размером 256 КиБ (на 32-битных платформах) или 1 МиБ на 64-битных платформах. Если распределения больше 512 байтов, он возвращается к использованию PyMem_RawMalloc() и PyMem_RawRealloc().
Rust медленнее, чем Python со стандартным распределителем памяти?
Я подозреваю, что эта проблема вызвана mmap
. Что, если мы перейдём на jemalloc
?
rust-std-fs-read-with-jemalloc:
use std::io::Read;
use std::fs::OpenOptions;
#[global_allocator]
static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc;
fn main() {
let mut bs = vec![0; 64 * 1024 * 1024];
let mut f = OpenOptions::new().read(true).open("/tmp/file").unwrap();
let mut ts = 0;
loop {
let buf = &mut bs[ts..];
let n = f.read(buf).unwrap();
let n = n as usize;
if n == 0 {
break
}
ts += n;
}
assert_eq!(ts, 64 * 1024 * 1024);
}
Ого-о-о!
Benchmark 1: rust-std-fs-read-with-jemalloc/target/release/test
Time (mean ± σ): 9.7 ms ± 0.6 ms [User: 0.3 ms, System: 9.4 ms]
Range (min … max): 9.0 ms … 12.4 ms 259 runs
Benchmark 2: python-fs-read/test.py
Time (mean ± σ): 15.8 ms ± 0.9 ms [User: 5.9 ms, System: 9.8 ms]
Range (min … max): 15.0 ms … 21.8 ms 169 runs
Summary
rust-std-fs-read-with-jemalloc/target/release/test ran
1.64 ± 0.14 times faster than python-fs-read/test.py
Что?! Я понимаю, что jemalloc
— качественный распределитель памяти, но как он может быть настолько успешным? Это совершенно невероятно.
Rust медленнее, чем Python только на моей машине!
Когда к обсуждению присоединились мои друзья, выяснилось, что Rust работает медленнее, чем Python только на моей машине.
Мой CPU:
> lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Address sizes: 48 bits physical, 48 bits virtual
Byte Order: Little Endian
CPU(s): 32
On-line CPU(s) list: 0-31
Vendor ID: AuthenticAMD
Model name: AMD Ryzen 9 5950X 16-Core Processor
CPU family: 25
Model: 33
Thread(s) per core: 2
Core(s) per socket: 16
Socket(s): 1
Stepping: 0
Frequency boost: enabled
CPU(s) scaling MHz: 53%
CPU max MHz: 5083.3979
CPU min MHz: 2200.0000
BogoMIPS: 6787.49
Flags: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx mmxext fxsr_opt pdpe1gb rdtscp lm con
stant_tsc rep_good nopl nonstop_tsc cpuid extd_apicid aperfmperf rapl pni pclmulqdq monitor ssse3 fma cx16 sse4_1 sse4_2 movbe popcnt aes xsave avx f
16c rdrand lahf_lm cmp_legacy svm extapic cr8_legacy abm sse4a misalignsse 3dnowprefetch osvw ibs skinit wdt tce topoext perfctr_core perfctr_nb bpex
t perfctr_llc mwaitx cpb cat_l3 cdp_l3 hw_pstate ssbd mba ibrs ibpb stibp vmmcall fsgsbase bmi1 avx2 smep bmi2 erms invpcid cqm rdt_a rdseed adx smap
clflushopt clwb sha_ni xsaveopt xsavec xgetbv1 xsaves cqm_llc cqm_occup_llc cqm_mbm_total cqm_mbm_local user_shstk clzero irperf xsaveerptr rdpru wb
noinvd arat npt lbrv svm_lock nrip_save tsc_scale vmcb_clean flushbyasid decodeassists pausefilter pfthreshold avic v_vmsave_vmload vgif v_spec_ctrl
umip pku ospke vaes vpclmulqdq rdpid overflow_recov succor smca fsrm debug_swap
Virtualization features:
Virtualization: AMD-V
Caches (sum of all):
L1d: 512 KiB (16 instances)
L1i: 512 KiB (16 instances)
L2: 8 MiB (16 instances)
L3: 64 MiB (2 instances)
NUMA:
NUMA node(s): 1
NUMA node0 CPU(s): 0-31
Vulnerabilities:
Gather data sampling: Not affected
Itlb multihit: Not affected
L1tf: Not affected
Mds: Not affected
Meltdown: Not affected
Mmio stale data: Not affected
Retbleed: Not affected
Spec rstack overflow: Vulnerable
Spec store bypass: Vulnerable
Spectre v1: Vulnerable: __user pointer sanitization and usercopy barriers only; no swapgs barriers
Spectre v2: Vulnerable, IBPB: disabled, STIBP: disabled, PBRSB-eIBRS: Not affected
Srbds: Not affected
Tsx async abort: Not affected
Моя память:
> sudo dmidecode --type memory
# dmidecode 3.5
Getting SMBIOS data from sysfs.
SMBIOS 3.3.0 present.
Handle 0x0014, DMI type 16, 23 bytes
Physical Memory Array
Location: System Board Or Motherboard
Use: System Memory
Error Correction Type: None
Maximum Capacity: 64 GB
Error Information Handle: 0x0013
Number Of Devices: 4
Handle 0x001C, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0014
Error Information Handle: 0x001B
Total Width: 64 bits
Data Width: 64 bits
Size: 16 GB
Form Factor: DIMM
Set: None
Locator: DIMM 0
Bank Locator: P0 CHANNEL A
Type: DDR4
Type Detail: Synchronous Unbuffered (Unregistered)
Speed: 3200 MT/s
Manufacturer: Unknown
Serial Number: 04904740
Asset Tag: Not Specified
Part Number: LMKUFG68AHFHD-32A
Rank: 2
Configured Memory Speed: 3200 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Memory Technology: DRAM
Memory Operating Mode Capability: Volatile memory
Firmware Version: Unknown
Module Manufacturer ID: Bank 9, Hex 0xC8
Module Product ID: Unknown
Memory Subsystem Controller Manufacturer ID: Unknown
Memory Subsystem Controller Product ID: Unknown
Non-Volatile Size: None
Volatile Size: 16 GB
Cache Size: None
Logical Size: None
Я попробовал следующие решения:
Включение мер защиты
В CPU есть множество уязвимостей, способных раскрывать нападающим приватные данные; самая известная из них — это Spectre
. Для ядра Linux было разработано множество мер защиты для борьбы с этими уязвимостями, и по умолчанию они включены. Однако эти меры могут накладывать на систему дополнительную нагрузку. Поэтому ядро Linux представляет желающим отключить их пользователям флаг mitigations
.
Раньше я отключал все mitigations
следующим образом:
title Arch Linux
linux /vmlinuz-linux-zen
initrd /amd-ucode.img
initrd /initramfs-linux-zen.img
options root="PARTUUID=206e7750-2b89-419d-978e-db0068c79c52" rw mitigations=off
Их включение никак не изменило результат.
Настройка Transparent Hugepage
Transparent Hugepage может существенно влиять на производительность. Большинство современных сборок включает это по умолчанию.
> cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never
Переключение на madvise
или never
влияет на абсолютный результат, но относительное соотношение остаётся неизменным.
Настройка CPU Core Affinity
@Manjusaka предположил, что это связано с CPU Core Spacing
.
Я попробовал воспользоваться core_affinity , чтобы привязать процесс к конкретному CPU, но результат оказался таким же.
Измерение задержек системных вызовов при помощи eBPF
@Manjusaka написал для меня программу eBPF, измеряющую задержки системных вызовов чтения. Из её показателей следует, что Rust медленнее, чем Python и на уровне системных вызовов.
Об этой программе eBPF тоже есть длинная история, которую @Manjusaka должен рассказать в отдельном посте!
# python fs read
Process 57555 read file 8134049 ns
Process 57555 read file 942 ns
# rust std fs read
Process 57634 read file 24636975 ns
Process 57634 read file 1052 ns
Наблюдение: на моём компьютере Rust работает медленнее, чем Python и, похоже, это не связано с ПО.
C медленнее, чем Python?
Я зашёл в тупик и не мог понять, в чём разница. Подозреваю, это как-то связано с CPU, но не уверен, в каком аспекте: кэш? частота? core spacing? core affinity? архитектура?
По рекомендациям из Telegram-группы @rust_zh я разработал версию на C:
#include <stdio.h>
#include <stdlib.h>
#define FILE_SIZE 64 * 1024 * 1024 // 64 MiB
int main() {
FILE *file;
char *buffer;
size_t result;
file = fopen("/tmp/file", "rb");
if (file == NULL) {
fputs("Error opening file", stderr);
return 1;
}
buffer = (char *)malloc(sizeof(char) * FILE_SIZE);
if (buffer == NULL) {
fputs("Memory error", stderr);
fclose(file);
return 2;
}
result = fread(buffer, 1, FILE_SIZE, file);
if (result != FILE_SIZE) {
fputs("Reading error", stderr);
fclose(file);
free(buffer);
return 3;
}
fclose(file);
free(buffer);
return 0;
}
Однако...
Benchmark 1: c-fs-read/test
Time (mean ± σ): 23.8 ms ± 0.9 ms [User: 0.3 ms, System: 23.6 ms]
Range (min … max): 23.0 ms … 27.1 ms 120 runs
Benchmark 2: python-fs-read/test.py
Time (mean ± σ): 19.1 ms ± 0.3 ms [User: 8.6 ms, System: 10.4 ms]
Range (min … max): 18.6 ms … 20.6 ms 146 runs
Summary
python-fs-read/test.py ran
1.25 ± 0.05 times faster than c-fs-read/test
Версия на C тоже медленнее, чем на Python! У Python есть какая-то магия?
C медленнее, чем Python без указанного смещения!
В этот момент к обсуждению присоединился @lilydjwg и заметил разницу в смещении области памяти между C и Python.
Для вывода недекодированных аргументов системных вызовов используется
strace -e raw=read,mmap ./program
: адрес указателя.
strace дляc-fs-read
:
> strace -e raw=read,mmap ./c-fs-read/test
...
mmap(0, 0x4001000, 0x3, 0x22, 0xffffffff, 0) = 0x7f96d1a18000
read(0x3, 0x7f96d1a18010, 0x4000000) = 0x4000000
close(3) = 0
strace для python-fs-read
> strace -e raw=read,mmap ./python-fs-read/test.py
...
mmap(0, 0x4001000, 0x3, 0x22, 0xffffffff, 0) = 0x7f27dcfbe000
read(0x3, 0x7f27dcfbe030, 0x4000001) = 0x4000000
read(0x3, 0x7f27e0fbe030, 0x1) = 0
close(3) = 0
Вc-fs-read
mmap
возвращает 0x7f96d1a18000
, но системный вызов чтения использует в качестве начального адреса0x7f96d1a18010
, смещение равно 0x10
. В python-fs-read
mmap
возвращает 0x7f27dcfbe000
, а системный вызов чтения использует в качестве начального адреса 0x7f27dcfbe030
, смещение равно 0x30
.
Поэтому @lilydjwg попробовал вызвать read
с тем же смещением:
:) ./bench c-fs-read c-fs-read-with-offset python-fs-read
['hyperfine', 'c-fs-read/test', 'c-fs-read-with-offset/test', 'python-fs-read/test.py']
Benchmark 1: c-fs-read/test
Time (mean ± σ): 23.7 ms ± 0.8 ms [User: 0.2 ms, System: 23.6 ms]
Range (min … max): 23.0 ms … 25.5 ms 119 runs
Warning: Statistical outliers were detected. Consider re-running this benchmark on a quiet system without any interferences from other programs. It might help to use the '--warmup' or '--prepare' options.
Benchmark 2: c-fs-read-with-offset/test
Time (mean ± σ): 8.9 ms ± 0.4 ms [User: 0.2 ms, System: 8.8 ms]
Range (min … max): 8.3 ms … 10.6 ms 283 runs
Benchmark 3: python-fs-read/test.py
Time (mean ± σ): 19.1 ms ± 0.3 ms [User: 8.6 ms, System: 10.4 ms]
Range (min … max): 18.6 ms … 20.0 ms 147 runs
Summary
c-fs-read-with-offset/test ran
2.15 ± 0.11 times faster than python-fs-read/test.py
2.68 ± 0.16 times faster than c-fs-read/test
!!!
Применив смещение к buffer
в c-fs-read
, мы повысили его скорость, обогнав Python! Кроме того, мы подтвердили, что проблема воспроизводится и наAMD Ryzen 9 5900X
, и на AMD Ryzen 7 5700X
.
Новая информация привела меня к другим отчётам о похожей проблеме: Std::fs::read slow? В этом посте @ambiso обнаружил, что производительность системных вызовов связана со смещением области памяти. Он показал, что CPU замедляется, выполняя запись из первых 0x10
байтов каждой страницы:
offset milliseconds
...
14 130
15 130
16 46 <----- 0x10!
17 48
...
AMD Ryzen 9 5900X медленный без указания смещения!
Мы доказали, что эта проблема связана с CPU, однако до сих пор не в курсе потенциальных причин. @Manjusaka пригласил присоединиться к обсуждению разработчика ядра @ryncsn.
Он смог воспроизвести тот же результат, запустив демо c-fs-read
и c-fs-read-with-offset
на AMD Ryzen 9 5900HX
. Также он попробовал профилировать эти две программы при помощи perf
.
Без смещения:
perf stat -d -d -d --repeat 20 ./a.out
Performance counter stats for './a.out' (20 runs):
30.89 msec task-clock # 0.968 CPUs utilized ( +- 1.35% )
0 context-switches # 0.000 /sec
0 cpu-migrations # 0.000 /sec
598 page-faults # 19.362 K/sec ( +- 0.05% )
90,321,344 cycles # 2.924 GHz ( +- 1.12% ) (40.76%)
599,640 stalled-cycles-frontend # 0.66% frontend cycles idle ( +- 2.19% ) (42.11%)
398,016 stalled-cycles-backend # 0.44% backend cycles idle ( +- 22.41% ) (41.88%)
43,349,705 instructions # 0.48 insn per cycle
# 0.01 stalled cycles per insn ( +- 1.32% ) (41.91%)
7,526,819 branches # 243.701 M/sec ( +- 5.01% ) (41.22%)
37,541 branch-misses # 0.50% of all branches ( +- 4.62% ) (41.12%)
127,845,213 L1-dcache-loads # 4.139 G/sec ( +- 1.14% ) (39.84%)
3,172,628 L1-dcache-load-misses # 2.48% of all L1-dcache accesses ( +- 1.34% ) (38.46%)
<not supported> LLC-loads
<not supported> LLC-load-misses
654,651 L1-icache-loads # 21.196 M/sec ( +- 1.71% ) (38.72%)
2,828 L1-icache-load-misses # 0.43% of all L1-icache accesses ( +- 2.35% ) (38.67%)
15,615 dTLB-loads # 505.578 K/sec ( +- 1.28% ) (38.82%)
12,825 dTLB-load-misses # 82.13% of all dTLB cache accesses ( +- 1.15% ) (38.88%)
16 iTLB-loads # 518.043 /sec ( +- 27.06% ) (38.82%)
2,202 iTLB-load-misses # 13762.50% of all iTLB cache accesses ( +- 23.62% ) (39.38%)
1,843,493 L1-dcache-prefetches # 59.688 M/sec ( +- 3.36% ) (39.40%)
<not supported> L1-dcache-prefetch-misses
0.031915 +- 0.000419 seconds time elapsed ( +- 1.31% )
Со смещением:
perf stat -d -d -d --repeat 20 ./a.out
Performance counter stats for './a.out' (20 runs):
15.39 msec task-clock # 0.937 CPUs utilized ( +- 3.24% )
1 context-switches # 64.972 /sec ( +- 17.62% )
0 cpu-migrations # 0.000 /sec
598 page-faults # 38.854 K/sec ( +- 0.06% )
41,239,117 cycles # 2.679 GHz ( +- 1.95% ) (40.68%)
547,465 stalled-cycles-frontend # 1.33% frontend cycles idle ( +- 3.43% ) (40.60%)
413,657 stalled-cycles-backend # 1.00% backend cycles idle ( +- 20.37% ) (40.50%)
37,009,429 instructions # 0.90 insn per cycle
# 0.01 stalled cycles per insn ( +- 3.13% ) (40.43%)
5,410,381 branches # 351.526 M/sec ( +- 3.24% ) (39.80%)
34,649 branch-misses # 0.64% of all branches ( +- 4.04% ) (39.94%)
13,965,813 L1-dcache-loads # 907.393 M/sec ( +- 3.37% ) (39.44%)
3,623,350 L1-dcache-load-misses # 25.94% of all L1-dcache accesses ( +- 3.56% ) (39.52%)
<not supported> LLC-loads
<not supported> LLC-load-misses
590,613 L1-icache-loads # 38.374 M/sec ( +- 3.39% ) (39.67%)
1,995 L1-icache-load-misses # 0.34% of all L1-icache accesses ( +- 4.18% ) (39.67%)
16,046 dTLB-loads # 1.043 M/sec ( +- 3.28% ) (39.78%)
14,040 dTLB-load-misses # 87.50% of all dTLB cache accesses ( +- 3.24% ) (39.78%)
11 iTLB-loads # 714.697 /sec ( +- 29.56% ) (39.77%)
3,657 iTLB-load-misses # 33245.45% of all iTLB cache accesses ( +- 14.61% ) (40.30%)
395,578 L1-dcache-prefetches # 25.702 M/sec ( +- 3.34% ) (40.10%)
<not supported> L1-dcache-prefetch-misses
0.016429 +- 0.000521 seconds time elapsed ( +- 3.17% )
Он обнаружил, что значение L1-dcache-prefetches
и L1-dcache-loads
сильно различается.
L1-dcache-prefetches
— выборки с упреждением кэша данных L1 CPU.L1-dcache-loads
— загрузки кэша данных L1 CPU.
Без указания смещения CPU выполняет больше загрузок и выборок с упреждением L1-dcache
, что приводит к увеличению времени системных вызовов.
Он провёл углублённое исследование горячей точки ASM:
Samples: 15K of event 'cycles:P', Event count (approx.): 6078132137
Children Self Command Shared Object Symbol
- 94.11% 0.00% a.out [kernel.vmlinux] [k] entry_SYSCALL_64_after_hwframe ◆
- entry_SYSCALL_64_after_hwframe ▒
- 94.10% do_syscall_64 ▒
- 86.66% __x64_sys_read ▒
ksys_read ▒
- vfs_read ▒
- 85.94% shmem_file_read_iter ▒
- 77.17% copy_page_to_iter ▒
- 75.80% _copy_to_iter ▒
+ 19.41% asm_exc_page_fault ▒
0.71% __might_fault ▒
+ 4.87% shmem_get_folio_gfp ▒
0.76% folio_mark_accessed ▒
+ 4.38% __x64_sys_munmap ▒
+ 1.02% 0xffffffffae6f6fe8 ▒
+ 0.79% __x64_sys_execve ▒
+ 0.58% __x64_sys_mmap ▒
Внутри_copy_to_iter
ASM выглядит так:
│ copy_user_generic():
2.19 │ mov %rdx,%rcx
│ mov %r12,%rsi
92.45 │ rep movsb %ds:(%rsi),%es:(%rdi)
0.49 │ nop
│ nop
│ nop
Главное различие здесь — производительность rep movsb
.
AMD Ryzen 9 5900X медленный при FSRM!
В это время один из моих друзей отправил мне ссылку об ужасной производительности memcpy на Zen 3 при использовании rep movsb. В ней тоже упоминалась rep movsb
:
Я обнаружил это при помощи бенчмарка memcpy https://github.com/ska-sa/katgpucbf/blob/69752be58fb8ab0668ada806e0fd809e782cc58b/scratch/memcpy_loop.cpp (скомпилированного при помощи находящегося там же Makefile). Для демонстрации проблемы выполните
./memcpy_loop -b 2113 -p 1000000 -t mmap -S 0 -D 1 0
При этом происходит следующее:
2113-байтные копирования в памяти
1000000 раз за измеряемое время
в памяти, распределённой при помощи mmap
с 0 байтами источника от начала страницы
с 1 байтом адресата от начала страницы
в ядре 0.
Результат равен примерно 3,2 ГБ/с. Если изменить аргумент на 2111, то результат станет больше 100 ГБ/с. То есть случай с REP MOVSB примерно в тридцать раз медленнее!
FSRM
(Fast Short REP MOV)
— это инновация Intel, недавно внедрённая и в архитектуры AMD; она повышает скорость rep movsb
и rep movsd
. Она спроектирована с целью повышения эффективности больших объёмов памяти. CPU, в которых заявлена её поддержка, по умолчанию используют glibc
в FSRM
.
@ryncsn провёл дополнительные исследования и обнаружил, что это не связано с упреждающими выборками L1.
Похоже, производительность
rep movsb
низка, когда данные выровнены по страницам, и она выше, когда данные не выровнены по страницам, что очень забавно...
Вывод
Проблема не связана с ПОconclusion. Python обгоняет по производительности C/Rust из-за бага в процессоре AMD. (И я наконец-то могу спать спокойно.)
Однако наши пользователи по-прежнему испытывают трудности с этой проблемой. К сожалению, функции наподобие FSRM
будут реализованы в ucode
, не оставляя нам никакого выбора, кроме как ждать ответа AMD. Альтернативное решение может заключаться в том, чтобы не использовать FSRM
или предоставить флаг для её отключения. Разработчикам на Rust стоит задуматься о переходе jemallocator
для повышения производительности, это выигрышный ход даже без наличия багов в процессорах AMD.
Дополнения
На разбор этой проблемы я потратил почти три дня. Всё началось с жалоб пользователей opendal и в конечном итоге привело меня к ucode CPU.
В ходе анализа я многое узнал о strace
, perf
и eBPF
. Я впервые использовал eBPF
для диагностики. Также я исследовал множество различных бесплодных догадок, например, изучил реализацииstd::fs
в Rust и подробности реализации чтения в Python и CPython. Поначалу я надеялся разрешить проблему на высоком уровне, но оказалось, что нужно погрузиться глубже.
Большое спасибо всем тем, кто поучаствовал в поиске ответа:
@beldathas из discord opendal за выявление проблемы.
Команде @datafuselabs за её полезные рекомендации.
Нашим друзьям из @rust_zh (telegram-группа по Rust, которая в основном ведётся на ханьском языке) за советы и попытки воспроизведения проблемы.
@Manjusaka за воспроизведение проблемы и использование eBPF для её изучения, что помогло сузить круг поисков до самого системного вызова.
@lilydjwg за выявление первопричины: смещения
0x20
в памяти@ryncsn за его тщательный анализ
И другу, который поделился полезными ссылками о FSRM
Ссылки
Xuanwo/when-i-find-rust-is-slow — все фрагменты кода и скрипты.
Std::fs::read slow? — отчёт из сообщества Rust
Terrible memcpy performance on Zen 3 when using rep movsb — отчёт по ubuntu glibc
Обновления
Эта статья, написанная 29 ноября 2023 года, привлекла большое внимание! Чтобы не запутывать читателей, я решил не изменять оригинальное содержание; обеспечивать актуальность информации и отвечать на популярные вопросы я буду в обновлениях.
1 декабря 2023 года: знает ли AMD об этом баге?
TL;DR: Да
Насколько я знаю, AMD было известно об этом баге ещё с 2021 года. После публикации статьи многие читатели отправили ссылку в AMD, так что я уверен, что компания о ней проинформирована.
Я уверен, что AMD должна взять на себя ответственность за этот баг и устранить его в amd-ucode
. Однако из неподтверждённых источников стало известно, что исправление через amd-ucode
маловероятно (по крайней мере, для Zen 3) из-за ограниченного пространства в патче. Если у вас есть какая-то информация по этому вопросу, прошу написать мне.
Наша единственная надежда — изменения в glibc, позволяющие по необходимости отключать FSRM. На этом фронте заметен прогресс: x86: Improve ERMS usage on Zen3. Следите за обновлениями.
1 декабря 2023 года: jemalloc или pymalloc быстрее, чем malloc из glibc?
TL;DR: Нет
Прошу прощения за то, что подробно не написал о связи между распределителем памяти и этим багом. Из поста может показаться, что jemalloc
(pymalloc
, mimalloc
) существенно быстрее, чем malloc из glibc. Однако это не так. Проблема не связана с распределителем памяти. Разница в скорости, из-за которой jemalloc
и pymalloc
обгоняют malloc glibc, возникла из-за разных смещений области памяти.
Давайте проанализируем strace rust-std-fs-read
и rust-std-fs-read-with-jemalloc
:
strace для rust-std-fs-read
:
> strace -e raw=read,mmap ./rust-std-fs-read/target/release/test
...
mmap(0, 0x4001000, 0x3, 0x22, 0xffffffff, 0) = 0x7f39a6e49000
openat(AT_FDCWD, "/tmp/file", O_RDONLY|O_CLOEXEC) = 3
read(0x3, 0x7f39a6e49010, 0x4000000) = 0x4000000
read(0x3, 0x7f39aae49010, 0) = 0
close(3) = 0
strace для rust-std-fs-read-with-jemalloc
:
> strace -e raw=read,mmap ./rust-std-fs-read-with-jemalloc/target/release/test
...
mmap(0, 0x200000, 0x3, 0x4022, 0xffffffff, 0) = 0x7f7a5a400000
mmap(0, 0x5000000, 0x3, 0x4022, 0xffffffff, 0) = 0x7f7a55400000
openat(AT_FDCWD, "/tmp/file", O_RDONLY|O_CLOEXEC) = 3
read(0x3, 0x7f7a55400740, 0x4000000) = 0x4000000
read(0x3, 0x7f7a59400740, 0) = 0
close(3) = 0
В rust-std-fs-read
mmap
возвращает 0x7f39a6e49000
, но системный вызов чтения использует в качестве начального адреса 0x7f39a6e49010
, смещение равно 0x10
. В rust-std-fs-read-with-jemalloc
mmap
возвращает 0x7f7a55400000
, а системный вызов чтения использует в качестве начального адреса 0x7f7a55400740
, смещение равно0x740
.
rust-std-fs-read-with-jemalloc
обгоняетrust-std-fs-read
из-за большего смещения, которое выходит за пределы проблемного интервала: 0x00..0x10
страницы. Эту проблему можно воспроизвести и с jemalloc
.
Комментарии (9)
onegreyonewhite
02.12.2023 13:40-1Я сталкивался с подобной магией, когда экспериментировал с Cython. На самом деле, если глубоко не вникать в код, память и т.д., то очень часто переписать на C это хуже. Не поймите неправильно, сам по себе C быстрее Python. Проблема в разработчиках и накладных расходах на разработку.
Поэтому надо быть крайне осторожным при реализации части функциональности на более быстрых языках, проводя тестирование на разных архитектурах, процессорах (с разными кешами и наборами инструкций), разной памятью и версиями ядер, набора библиотек и т.д.
Но у нас как обычно: "давайте перепишем на язык X и сразу ускоримся в N раз!" Кекас два. Потом запускаются в облаке, на каком-то старом Linux ядре 4.4, дешёвых энергоэффективных процессорах и скорость просто пополам ниже.
chernish2
02.12.2023 13:40-1Недавно столкнулся с похожей ситуацией. Есть очень простой тест скорости доступа до эндпойнта на Ruby: 5000 раз сделать REST-запрос и распарсить полученный JSON. Из любопытства переписал тест на C++, и вопреки ожиданиям среднее время доступа увеличилось процентов на 5. Проверял на домашнем десктопе (Ryzen 5) и облачных виртуалках. Пробовал разные библиотеки для работы с JSON в C++, но замедление сохранялось. В причинах разбираться не стал, поскольку не обладаю необходимой квалификацией.
Cerberuser
02.12.2023 13:40+3А вот кстати говоря:
Benchmark 1: python-fs-read/test.py
Time (mean ± σ): 15.9 ms ± 0.7 ms [User: 5.6 ms, System: 10.1 ms]
Benchmark 2: python-opendal-read/test.py
Time (mean ± σ): 32.9 ms ± 1.3 ms [User: 6.1 ms, System: 26.6 ms]Нельзя ли было какой-то вывод сделать уже на этом этапе?
Mogost
02.12.2023 13:40+2Это просто великолепно. Читаешь как некий детектив, при этом финал неожиданный. Спасибо большое за эту статью.
TIEugene
И не одна.
Лет X назад писал то, что потом было обнаружено как rpmreaper/deborphan - выявление пакетов-leaves. Макет на питоне, "прод" на C.
Внезапно код на C оказался в разы медленнее (понятно, что спец-средств не применялось, stdlib only).
Трассировка показала, что поиск в строках в питоне значительно быстрее, а перераспределение памяти в разы быстрее. Это если не выпендриваться.
Как так - не знаю, но fuckт имел место быть.