В этой статье я поделюсь с вами долгой историей, которая начинается с op.read() opendal, а заканчивается неожиданным поворотом. Это путешествие оказалось для меня достаточно поучительным, надеюсь, и для вас оно будет таким же. Я постараюсь максимально точно воссоздать свой опыт и дополню его выводами, которые сделал в процессе. Все фрагменты кода и скрипты выложены в Xuanwo/when-i-find-rust-is-slow Если вам срочно нужно знать ответ, сразу перейдите к разделу «Вывод». OpenDAL — это слой доступа к данным, позволяющий удобно и эффективно получать данные из различных сервисов хранения единым образом. Мы предоставляем для OpenDAL привязку к Python при помощи pyo3. Однажды @beldathas написал мне в discord о том, что привязка OpenDAL к python медленее, чем python: Результаты демонстрируют следующее: Так-так-так. Меня неприятно поразили эти результаты. У меня быстро возникло несколько гипотез: У Python есть внутренний кэш, способный многократно использовать одну и ту же память? У Python есть какой-то трюк, ускоряющий чтение файлов? Лишняя трата ресурсов возникает из-за PyO3? Я отрефакторил код: python-fs-read: python-opendal-read: Результаты показывают, что python намного быстрее opendal: Привязка OpenDAL к Python кажется более медленной, чем сам Python, и это печальная новость. Давайте разберёмся, в чём причина. В этом пазле задействовано множество элементов, в том числе rust, opendal, python и pyo3. Попробуем выявить первопричину. Я реализовал ту же логику через сервис opendal fs на rust: rust-opendal-fs-read: Однако результат показывает, что opendal медленнее, чем python, даже если opendal реализован на rust: Хотя показателиrust-opendal-fs-read немного лучше, чем у python-opendal-read, что демонстрирует потенциал совершенствования привязки и pyo3, всё это не первопричины проблемы. Нам нужно двигаться глубже. Сервис opendal fs медленнее, чем python. OpenDAL реализует сервис fs через std::fs. Возможно, дополнительные затраты вызваны самим OpenDAL? Мы реализуем ту же логику через std::fs rust: rust-std-fs-read: Но... Ого, 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 — дескриптор файла, на который выполняется ссылка в последующем системном вызове. Отлично, мы в совершенстве освоили strace. Давайте воспользуемся им! Результаты strace для rust-std-fs-read: Результаты strace для python-fs-read: Из анализа strace становится понятно, что в python-fs-read имеется больше системных вызовов, чем в rust-std-fs-read, и оба они используют mmap. Так почему же python быстрее, чем rust? Изначально я думал, что mmap нужна только для отображения файлов в память, что позволяет получать доступ к файлам через память. Однако mmap применяется и иным образом. Часто её используют для распределения больших областей памяти для приложений. Это можно увидеть в результатах strace: Этот системный вызов означает следующее: NULL: первый аргумент — это начальный адрес области памяти для отображения. NULL позволяет операционной системе самой выбрать подходящий адрес. 67112960: размер области памяти для отображения. Здесь мы распределяем 64 МиБ + 4 КиБ памяти, дополнительная страница используется для хранения метаданных этой области памяти. PROT_READ|PROT_WRITE: область памяти доступна для чтения и записи. MAP_PRIVATE|MAP_ANONYMOUS: MAP_PRIVATE означает, что изменения в этой области памяти не будут видимы для других процессов, выполняющих отображение в ту же область, и что они не переносятся в соответствующий файл (если он есть). MAP_ANONYMOUS означает, что мы распределяем анонимную память, не связанную с файлом. -1: дескриптор файла, отображение которого мы выполняем. -1 означает, что мы не отображаем файл. 0: смещение в файле, с которого нужно выполнять отображение. Здесь использован 0, потому что мы не отображаем файл. Системный вызовmmap диспетчеризируется библиотекойglibc. Мы используем malloc для запроса памяти у системы, а в ответ glibc задействует системные вызовы brk и mmap для распределения памяти согласно запрошенному нами размеру. Если запрошенный размер достаточно велик, то glibc применяет mmap, которая позволяет устранять проблемы фрагментации памяти. По умолчанию все программы на Rust, компилируемые под целевую платформу x86_64-unknown-linux-gnu, используют malloc, предоставляемую glibc. По умолчанию Python использует pymalloc — распределитель памяти, оптимизированный под небольшие распределения. Python имеет три диапазона памяти, каждый из который отражает разные стратегии распределения и оптимизирован под разные цели. pymalloc имеет следующее поведение: У Python есть распределительpymalloc , оптимизированный под мелкие объекты (меньше или равные 512 байтам) с коротким временем жизни. Он использует отображения памяти, называемые «аренами» (arena) c фиксированным размером 256 КиБ (на 32-битных платформах) или 1 МиБ на 64-битных платформах. Если распределения больше 512 байтов, он возвращается к использованию PyMem_RawMalloc() и PyMem_RawRealloc(). Я подозреваю, что эта проблема вызвана mmap. Что, если мы перейдём на jemalloc? rust-std-fs-read-with-jemalloc: Ого-о-о! Что?! Я понимаю, что jemalloc — качественный распределитель памяти, но как он может быть настолько успешным? Это совершенно невероятно. Когда к обсуждению присоединились мои друзья, выяснилось, что Rust работает медленнее, чем Python только на моей машине. Мой CPU: Моя память: Я попробовал следующие решения: В CPU есть множество уязвимостей, способных раскрывать нападающим приватные данные; самая известная из них — это Spectre. Для ядра Linux было разработано множество мер защиты для борьбы с этими уязвимостями, и по умолчанию они включены. Однако эти меры могут накладывать на систему дополнительную нагрузку. Поэтому ядро Linux представляет желающим отключить их пользователям флаг mitigations. Раньше я отключал все mitigations следующим образом: Их включение никак не изменило результат. Transparent Hugepage может существенно влиять на производительность. Большинство современных сборок включает это по умолчанию. Переключение на madvise или never влияет на абсолютный результат, но относительное соотношение остаётся неизменным. @Manjusaka предположил, что это связано с CPU Core Spacing. Я попробовал воспользоваться core_affinity , чтобы привязать процесс к конкретному CPU, но результат оказался таким же. @Manjusaka написал для меня программу eBPF, измеряющую задержки системных вызовов чтения. Из её показателей следует, что Rust медленнее, чем Python и на уровне системных вызовов. Об этой программе eBPF тоже есть длинная история, которую @Manjusaka должен рассказать в отдельном посте! Наблюдение: на моём компьютере Rust работает медленнее, чем Python и, похоже, это не связано с ПО. Я зашёл в тупик и не мог понять, в чём разница. Подозреваю, это как-то связано с CPU, но не уверен, в каком аспекте: кэш? частота? core spacing? core affinity? архитектура? По рекомендациям из Telegram-группы @rust_zh я разработал версию на C: c-fs-read: Однако... Версия на C тоже медленнее, чем на Python! У Python есть какая-то магия? В этот момент к обсуждению присоединился @lilydjwg и заметил разницу в смещении области памяти между C и Python. Для вывода недекодированных аргументов системных вызовов используется strace -e raw=read,mmap ./program : адрес указателя. strace дляc-fs-read: strace для python-fs-read Вc-fs-read mmap возвращает 0x7f96d1a18000, но системный вызов чтения использует в качестве начального адреса0x7f96d1a18010 , смещение равно 0x10. В python-fs-read mmap возвращает 0x7f27dcfbe000, а системный вызов чтения использует в качестве начального адреса 0x7f27dcfbe030, смещение равно 0x30. Поэтому @lilydjwg попробовал вызвать read с тем же смещением: !!! Применив смещение к buffer в c-fs-read , мы повысили его скорость, обогнав Python! Кроме того, мы подтвердили, что проблема воспроизводится и наAMD Ryzen 9 5900X, и на AMD Ryzen 7 5700X. Новая информация привела меня к другим отчётам о похожей проблеме: Std::fs::read slow? В этом посте @ambiso обнаружил, что производительность системных вызовов связана со смещением области памяти. Он показал, что CPU замедляется, выполняя запись из первых 0x10 байтов каждой страницы: Мы доказали, что эта проблема связана с CPU, однако до сих пор не в курсе потенциальных причин. @Manjusaka пригласил присоединиться к обсуждению разработчика ядра @ryncsn. Он смог воспроизвести тот же результат, запустив демо c-fs-read и c-fs-read-with-offset на AMD Ryzen 9 5900HX. Также он попробовал профилировать эти две программы при помощи perf. Без смещения: Со смещением: Он обнаружил, что значение L1-dcache-prefetches и L1-dcache-loads сильно различается. L1-dcache-prefetches — выборки с упреждением кэша данных L1 CPU. L1-dcache-loads — загрузки кэша данных L1 CPU. Без указания смещения CPU выполняет больше загрузок и выборок с упреждением L1-dcache, что приводит к увеличению времени системных вызовов. Он провёл углублённое исследование горячей точки ASM: Внутри_copy_to_iter ASM выглядит так: Главное различие здесь — производительность rep movsb. В это время один из моих друзей отправил мне ссылку об ужасной производительности 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 binding/python: rust std fs is slower than python fs Эта статья, написанная 29 ноября 2023 года, привлекла большое внимание! Чтобы не запутывать читателей, я решил не изменять оригинальное содержание; обеспечивать актуальность информации и отвечать на популярные вопросы я буду в обновлениях. TL;DR: Да Насколько я знаю, AMD было известно об этом баге ещё с 2021 года. После публикации статьи многие читатели отправили ссылку в AMD, так что я уверен, что компания о ней проинформирована. Я уверен, что AMD должна взять на себя ответственность за этот баг и устранить его в amd-ucode. Однако из неподтверждённых источников стало известно, что исправление через amd-ucode маловероятно (по крайней мере, для Zen 3) из-за ограниченного пространства в патче. Если у вас есть какая-то информация по этому вопросу, прошу написать мне. Наша единственная надежда — изменения в glibc, позволяющие по необходимости отключать FSRM. На этом фронте заметен прогресс: x86: Improve ERMS usage on Zen3. Следите за обновлениями. 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 для rust-std-fs-read-with-jemalloc: В 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.  Переводчик-фрилансер