- El punto de partida fue un reporte de que la lectura de archivos en el binding de Python de Apache OpenDAL era más lenta que
open().read()integrado de Python, pero el cuello de botella no estaba en OpenDAL ni en PyO3 en sí - En un benchmark de lectura de un archivo de 64 MiB,
python-fs-readmidió alrededor de 15~19 ms, mientras que Ruststd::fsy la implementación en C midieron cerca de 23 ms, haciendo parecer que Rust/C eran más lentos que Python - Al seguir la pista con
strace,eBPFyperf, la diferencia quedó vinculada al offset dentro de la página donde se ubicaba el buffer de destino de la syscallread, y la degradación de rendimiento se reprodujo cerca de0x10 - Se confirmó un fenómeno similar en familias AMD Ryzen 9 5900X, Ryzen 7 5700X y Ryzen 9 5900HX; la pista clave fue el rendimiento de ejecución de
rep movsbdentro de_copy_to_iterdel kernel - Python no era inherentemente más rápido: el resultado fue causado por una combinación fortuita de un bug de CPU relacionado con FSRM/
rep movsben AMD Zen 3 y el offset de memoria; la mejora conjemalloctampoco se debía al asignador en sí, sino a un offset distinto
Un benchmark extraño que empezó en el binding de Python de OpenDAL
- Apache OpenDAL es una capa de acceso a datos para leer y escribir datos de forma unificada en varios servicios de almacenamiento, y su binding de Python se ofrece mediante PyO3
- Un usuario reportó que el código que leía un archivo de 150 MB con el binding de Python de OpenDAL era más lento que la lectura de archivos integrada de Python
open(...).read()integrado de Python, 100 veces:4.470868484000675- Binding de Python de OpenDAL, 100 veces:
8.993250704006641
- Incluso en una lectura simplificada de un archivo de 64 MiB, el binding de OpenDAL era más lento
python-fs-read: promedio 15.9 mspython-opendal-read: promedio 32.9 ms- La lectura integrada de Python se midió 2.07 veces más rápida que el binding de OpenDAL
Seguimiento hasta Rust OpenDAL y std::fs
- Aunque se implementara la misma lógica con el servicio
fsde OpenDAL en Rust, seguía siendo más lenta que la lectura integrada de Pythonrust-opendal-fs-read: promedio 23.8 mspython-fs-read: promedio 15.6 ms- La lectura integrada de Python se midió 1.52 veces más rápida que la implementación de Rust OpenDAL
- Como el servicio
fsde OpenDAL usa std::fs de Rust, se escribió aparte una implementación basada enstd::fspara verificar el costo propio de OpenDAL - En la implementación directa con
std::fsde Rust se mantuvo la misma tendenciarust-std-fs-read: promedio 23.1 mspython-fs-read: promedio 15.2 ms- La lectura integrada de Python se midió 1.52 veces más rápida que
std::fsde Rust
Syscalls y mmap vistos con strace
- El análisis con
stracemostró que tanto Rust como Python usabanmmappara asignar buffers grandes - La ejecución de
std::fsde Rust abría/tmp/file, leía 64 MiB de una vez, llamaba areadpara confirmar el EOF y luego cerraba el archivo - La lectura integrada de Python ejecutaba más syscalls, como
newfstatat,ioctlylseek, pero el tiempo total era menor - La llamada
mmap(NULL, 67112960, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)se usaba para asignación de memoria anónima, no para mapear el archivo67112960es 64 MiB más 4 KiBMAP_ANONYMOUSsignifica una asignación de memoria no relacionada con un archivo
- La compilación predeterminada de Rust para
x86_64-unknown-linux-gnuusamallocdeglibc, yglibcpuede usarmmappara asignaciones grandes
Rust se aceleró con jemalloc y la conclusión intermedia se invirtió
- Al cambiar el asignador global de Rust a
jemallocator::Jemalloc, Rust pasó a ser más rápido que Pythonrust-std-fs-read-with-jemalloc: promedio 9.7 mspython-fs-read: promedio 15.8 ms- La implementación en Rust usando jemalloc se midió 1.64 veces más rápida que Python
- En ese momento parecía que la causa era
mmapo el asignador de memoria predeterminado, pero en una actualización posterior se corrigió la interpretación - Según la actualización del 2023-12-01, la diferencia no se debía a que
jemalloc,pymallocomimallocfueran inherentemente más rápidos queglibc malloc - La diferencia real venía del offset dentro de la página del buffer creado por el asignador
rust-std-fs-read: leía en un offset0x10desde la dirección inicial demmaprust-std-fs-read-with-jemalloc: leía en un offset0x740desde la dirección inicial demmap
- La zona problemática se delimitó al rango
0x00..0x10dentro de la página, y el mismo problema podía reproducirse también conjemalloc
Un problema cuya reproducibilidad dependía más del equipo que de la configuración de software
- A medida que avanzó la discusión, se confirmó que el fenómeno de Rust siendo más lento que Python era especialmente notable en el equipo del autor
- La CPU del autor era un AMD Ryzen 9 5950X 16-Core Processor, con una configuración de memoria DDR4 3200 MT/s 16GB DIMM
- Aunque se cambiaran varias configuraciones, la diferencia relativa de rendimiento no desaparecía
- Volver a activar
mitigations=offdel kernel Linux no cambiaba los resultados - Cambiar Transparent Hugepage entre
always,madviseynevermodificaba los valores absolutos, pero mantenía la proporción relativa - Fijar la ejecución a un núcleo específico de CPU con
core_affinitydaba el mismo resultado
- Volver a activar
- La medición de latencia de la syscall
readbasada en eBPF también mostraba que el lado de Rust era más lento- Python
read file: 8,134,049 ns - Rust
std::fsread file: 24,636,975 ns
- Python
- A partir de las observaciones, era difícil explicar la diferencia solo por OpenDAL, PyO3 o la biblioteca estándar de Rust; el tiempo ya divergía a nivel de syscall
La pista del offset de memoria revelada por la implementación en C
- Incluso implementando la misma lectura de un archivo de 64 MiB en C con
fopen/malloc/fread, era más lenta que Pythonc-fs-read: promedio 23.8 mspython-fs-read: promedio 19.1 ms- La lectura integrada de Python se midió 1.25 veces más rápida que la implementación en C
- Al revisar las direcciones de punteros con
strace -e raw=read,mmap, el offset inicial del buffer era distinto en C y Python- C:
readen un offset0x10desde la dirección devuelta pormmap - Python:
readen un offset0x30desde la dirección devuelta pormmap
- C:
- Al ajustar el offset de la misma forma en la implementación en C, el rendimiento mejoró notablemente
c-fs-read-with-offset: promedio 8.9 ms- 2.15 veces más rápido que Python y 2.68 veces más rápido que la implementación anterior en C
- Este problema también se reprodujo en AMD Ryzen 9 5900X y AMD Ryzen 7 5700X
- En Std::fs::read slow?, de la comunidad de Rust, también se reportó un fenómeno similar y se señaló la relación entre el offset del área de memoria y el rendimiento de las syscalls
El análisis con perf apuntó a rep movsb
- Un desarrollador del kernel reprodujo
c-fs-ready la versión con offset aplicado en unAMD Ryzen 9 5900HX, y los analizó conperf - Según hubiera o no offset, los valores de
L1-dcache-prefetchesyL1-dcache-loadsdiferían mucho- Sin offset:
L1-dcache-loadscerca de 127,845,213,L1-dcache-prefetchescerca de 1,843,493 - Con offset:
L1-dcache-loadscerca de 13,965,813,L1-dcache-prefetchescerca de 395,578
- Sin offset:
- El hotspot seguía la ruta de
readen el kernel:shmem_file_read_iter→copy_page_to_iter→_copy_to_iter - La instrucción ensamblador clave dentro de
_copy_to_itererarep movsb, y la mayoría de las muestras se concentraban en esa instrucción - En análisis posteriores, se concluyó que la pista más importante no era tanto el prefetch de L1 en sí, sino que el rendimiento de
rep movsbera malo con datos alineados a página y mejoraba cuando se rompía esa alineación
FSRM y el problema de AMD Zen 3
- El reporte de bug compartido de Ubuntu glibc, Terrible memcpy performance on Zen 3 when using rep movsb, también trata un problema de rendimiento de
rep movsb - El ejemplo de ese reporte explica que, en una copia de 2113 bytes, la ruta
rep movsbda alrededor de 3.2 GB/s, y al cambiar el tamaño a 2111 bytes sube a más de 100 GB/s - FSRM significa Fast Short REP MOV y es una función para acelerar
rep movsbyrep movsd - FSRM comenzó como una función de Intel y también fue incorporada por AMD; en CPU que declaran soporte,
glibcusa FSRM de forma predeterminada - Por lo tanto, Python no era inherentemente más rápido que C/Rust; la conclusión fue que, por un bug de CPU de AMD, la ruta de lectura de C/Rust se ralentizaba en ciertos offsets de memoria
Actualización: conocimiento de AMD y respuesta de glibc
- Según la actualización del 2023-12-01, se entiende que AMD conocía este bug desde 2021
- Como varios lectores enviaron el enlace a AMD después de la publicación del artículo, se asume que AMD conoce el problema
- El autor considera que AMD debería hacerse responsable de corregir este bug en
amd-ucode, aunque según información no confirmada podría ser difícil arreglarlo en Zen 3 medianteamd-ucode - La esperanza realista es que glibc desactive FSRM cuando sea necesario
- Del lado de glibc está en marcha el trabajo x86: Improve ERMS usage on Zen3
Código de reproducción y materiales relacionados
- Xuanwo/when-i-find-rust-is-slow: colección de fragmentos de código y scripts usados
- Std::fs::read slow?: reporte similar en la comunidad de Rust
- Terrible memcpy performance on Zen 3 when using rep movsb: problema de rendimiento de
rep movsben Zen 3 reportado a Ubuntu glibc - binding/python: rust std fs is slower than python fs: issue relacionado con el binding de Python de OpenDAL
1 comentarios
Opiniones de Hacker News
Hay nada menos que dos flags de función de CPU dedicados que indican que
REP STOS/MOVes rápido y que puede usarse como una secuencia corta de instrucciones paramemset/memcpy.El sufrimiento de tener que reescribir a mano rutinas optimizadas para cada nueva generación de CPU lleva décadas, y que esto siga así me hace pensar que debería estar incluido en la suite de pruebas de temporización de los proveedores de CPU.
Puede que hubiera un problema con
rep movsrápido alineado a página, o que fuera vulnerable a algún ataque y por eso se desactivara.No sé cómo debería ser la corrección, ni si haría falta algo como una verificación en tiempo de ejecución.
Si existe una implementación de “software” más rápida, me pregunto por qué no hacen que
REP MOVShaga al menos lo mismo en microcódigo.El bug relacionado de glibc está aquí. Aunque este caso es de Zen 4: https://sourceware.org/bugzilla/show_bug.cgi?id=30994
Al principio, al leer el artículo, me preparé para burlarme de que el autor había usado mal
std::fs, pero en realidad resultó ser un texto entretenido, con una madriguera de conejo de depuración y un misterio detrás de otro.Estuvo bien escrito y fue muy interesante.
La premisa me resulta un poco confusa. No se comparó código Python puro con código nativo en C/Rust, sino el método de lectura de archivos de Python, que es un wrapper de Python sobre código nativo, con OpenDAL, que es otro wrapper sobre código nativo.
Que haya una diferencia de rendimiento sigue siendo interesante, pero expresarlo como “más lento que Python” es bastante raro. Me pregunto si esperaban que toda la biblioteca estándar de Python estuviera escrita en Python puro. Más bien, esperaría que la implementación de las funciones de la biblioteca estándar de Python fuera nativa y estuviera muy optimizada individualmente.
No me sorprendió que la conclusión estuviera relacionada con cómo se comporta el código nativo, pero la respuesta concreta sí fue inesperada. Aun así, solo el punto de partida fue confuso; el artículo en sí fue muy interesante.
Además, el título “C is slower than Python with specified offset” para un hablante nativo se lee como “C es más lento que Python incluso con un offset especificado”. En realidad quería decir lo contrario: que al especificar en C el offset que se usaba en Python, C se volvió más rápido.
Que una tarea tan simple como leer un archivo sea más lenta en la biblioteca estándar de Rust que en la biblioteca estándar de Python es sorprendente. Aunque uno sepa que estas llamadas de la biblioteca estándar de Python están escritas en C, esperaría que la llamada de la biblioteca estándar de Rust tuviera una velocidad similar.
Por eso normalmente uno esperaría que el uso estuviera mal o que hubiera algún comportamiento extraño en la biblioteca estándar de Rust, pero esta vez no era ninguna de las dos cosas: era un precipicio de rendimiento en hardware específico según la alineación de la asignación.
Es de esperar que la lectura del sistema de archivos esté bien optimizada en Python, pero uno pensaría lo mismo de Rust, así que sorprende que el lado de Rust fuera mucho más lento, y más aún que dependiera del hardware y del asignador.
Si el código que escribí en Python es rápido, para mí Python es rápido. No me importa mucho si es porque la implementación está en otro lenguaje o por otra razón.
Lo que ocurrió en el artículo original es casi pura casualidad. El código C de CPython ni siquiera se preocupa por la consistencia de
const, y usa mucha asignación dinámica de memoria y llamadas auxiliares/de conveniencia. Incluso cosas como la aritmética hacen asignación dinámica de memoria.Si has trabajado con CPython, normalmente no esperas que tenga buen rendimiento. Cuando quieres mejorar el rendimiento, terminas intentando rodear las funciones que ofrece.
Además, Python no tiene un estándar, así que estrictamente hablando tampoco tiene una biblioteca estándar, y la biblioteca que se distribuye junto con él está escrita mayormente en Python. Algunas partes están escritas en C, pero dentro de ese código C también hay bastante que, en la práctica, es código Python trasladado mecánicamente a C. Por ejemplo, la implementación de búsqueda binaria de Python originalmente estaba escrita en Python y luego se tradujo a C usando la Python C API.
Lo que sí se puede esperar es que las funciones que simplemente mapean a funciones del sistema operativo tengan wrappers relativamente delgados. Es decir, la lectura de archivos entra esencialmente directo a una interfaz del sistema, así que no debería requerir mucho código de binding.
Después de que aparecieron decenas de artículos parecidos, todos se dieron cuenta.
El artículo en sí es excelente y contiene mucha información interesante relacionada con este tema.
Sin embargo, lo que más me llama la atención y me preocupa es cómo se reporta y registra el problema, y cómo se maneja la comunicación.
El reporte se hace en Discord, que es un entorno propietario, no se indexa, es difícil de buscar y tampoco se conserva. La discusión ocurre en Discord y Telegram; en este contexto, Telegram podría ser incluso peor.
Esta publicación del blog y el repositorio de GitHub son todo lo que queda como rastro. Si Xuanwo no lo hubiera escrito en el blog, se habría perdido en la línea de tiempo. Es una situación bastante interesante.
Casi ningún mensajero indexa y permite buscar por defecto logs con acceso público. No todos los servidores IRC ofrecen logs públicos, y lo mismo pasa con los grupos de Matrix. No entiendo por qué se supone que las discusiones ahí no se pierden en la línea de tiempo.
La razón por la que se pueden ofrecer logs públicos no es que no sea propietario, sino que existe una API que permite el registro. Telegram también tiene una API así, y los logs buscables de nuestro grupo de discusión pueden verse aquí: https://luoxu-web.vercel.app/#g=1264662201
Que no haya indexación pública se debe principalmente a la privacidad, no a que la plataforma sea propietaria.
Antes se podían buscar todos los posts de forma ordenada en DejaNews y luego en Google.
Las comunicaciones importantes de proyectos open source importantes, como el stack de Internet/WWW y las herramientas y bibliotecas centrales de programación, deberían funcionar sobre estándares abiertos.
Fue lo más interesante que leí esta semana. Excelente resumen.
Lo obvio parece ser enviar un parche al método del kernel
copy_user_generic.Si se detecta una CPU problemática y causa el bug que hace más lento el alineamiento de memoria, basta con hacer que use otra implementación de copia de memoria.
Una corrección que sea aceptable para alguien sin experiencia en el kernel no sería trivial. Más importante aún, tampoco es evidente cómo debería activarse la solución alternativa. Probablemente lo mejor sea medirlo durante el arranque; de lo contrario, no está claro cómo saber qué modelos y steppings están afectados.
La mitigación por software también sería complicada. El kernel no puede usar realmente las instrucciones vectoriales que normalmente se usan en la ruta alternativa cuando no se puede usar ERMS.
jemallocfue el asignador predeterminado de Rust hasta 2018.https://internals.rust-lang.org/t/jemalloc-was-just-removed-...
Me da curiosidad la parte de que “los desarrolladores de Rust podrían considerar cambiar a
jemallocatorpara mejorar el rendimiento”No sé si cualquiera puede obtener una mejora de rendimiento prácticamente gratis o si hay advertencias. También me pregunto si una base de código en C podría beneficiarse, o si es rendimiento que actualmente se está dejando pasar
jemallocpuede causar problemas de observabilidad porMADV_FREE.htopya no muestra con precisión la memoria que realmente está en usohttps://github.com/jemalloc/jemalloc/issues/387#issuecomment...
https://gitlab.haskell.org/ghc/ghc/-/issues/17411
Actualmente parece que
jemallocllama aMADV_DONTNEED10 segundos después deMADV_FREE: https://github.com/JuliaLang/julia/issues/51086#issuecomment...Así que “arregla” este problema, pero introduce un retraso confuso entre el momento en que se libera la memoria y el momento en que se puede observar ese hecho en
htopSin embargo, según https://jemalloc.net/jemalloc.3.html, se puede eliminar el retraso configurando
opt.muzzy_decay_ms = 0Aun así, el autor de musl se muestra reservado respecto de usar
jemalloccomo valor predeterminado: https://www.openwall.com/lists/musl/2018/04/23/2La idea es que tiene problemas de hinchamiento serio, debilitamiento de ASLR y optimizaciones sesgadas a hacerlo lo más rápido posible sin preocuparse por el uso de memoria. Esos valores de ajuste podrían mitigarlo en cierta medida, pero es probable que la tendencia general de enfocarse en rendimiento o en uso de memoria siga siendo un trade-off
No necesariamente será más rápido en todas las situaciones, pero probablemente lo sea en la gran mayoría. Rust también usaba
jemallocpor defecto antes, pero se cambió porque a algunas personas les parecía sorprendente como valor predeterminadoDepende mucho de la carga de trabajo, así que hacen falta profiling y benchmarking. Aun así, los lenguajes de bajo nivel como C/C++/Rust deberían poder elegir este tipo de asignadores
Una advertencia es el tamaño del binario. Un asignador personalizado agrega más bytes al ejecutable
jemallocpor defecto, pero alrededor de 2018 volvió almallocdel sistema[0]Ahora Rust tiene el trait
GlobalAllocy el atributo#[global_allocator], así que una app puede usarjemalloccomo asignador si quiere. No estoy seguro de si el usuario puede sobrescribirlo con algo comoLD_PRELOADjemallocno siempre es la mejor opción para todas las cargas de trabajo y casos de uso. Los asignadores del sistema suelen estar lejos de ser perfectos, pero al menos han sido ampliamente probados como asignadores de propósito general[0] https://github.com/rust-lang/rust/issues/36963
jemallocpuede ser la elección adecuada para algunas aplicaciones, pero en otros casos otro asignador podría ser más rápido. O, aunque sea más lento, podría ajustarse mejor a objetivos como menos memoria sucia, mejor observabilidad o ciertas garantías de seguridadEnvié esto a las personas adecuadas