Desarrollo de un generador de perfiles gráfico Python FunctionTrace





Hoy compartimos con ustedes una traducción de un artículo del creador de FunctionTrace, un generador de perfiles de Python con una interfaz gráfica intuitiva que puede generar perfiles de aplicaciones multiprocesador y multiproceso y utiliza un orden de magnitud menos de recursos que otros generadores de perfiles de Python. No importa si solo está aprendiendo desarrollo web en Python o si lo ha estado usando durante mucho tiempo; siempre es bueno comprender lo que está haciendo su código. Sobre cómo apareció este proyecto, sobre los detalles de su desarrollo, más abajo del corte.






Introducción



Firefox Profiler fue la piedra angular de Firefox durante la era del Proyecto Quantum . Cuando abre la entrada de ejemplo, ve una poderosa interfaz de análisis de rendimiento basada en web que incluye árboles de llamadas, diagramas de pila, diagramas de fuego y más. Todas las acciones de filtrado, escalado, corte y transformación de datos se guardan en una URL que se puede compartir. Los resultados se pueden compartir en un informe de error, sus hallazgos se pueden documentar, comparar con otros registros, o se puede transmitir información para futuras investigaciones. Firefox DevEdition tiene un hilo de creación de perfiles incorporado. Este flujo facilita la comunicación. Nuestro objetivo es capacitar a todos los desarrolladores, incluso fuera de Firefox, para que colaboren de manera productiva.



Anteriormente, Firefox Profiler importaba otros formatos comenzando con perfiles Linux perf y Chrome . Con el tiempo, los desarrolladores han agregado más formatos. Hoy, están surgiendo los primeros proyectos para adaptar Firefox a las herramientas de análisis. FunctionTrace es uno de esos proyectos. Matt cuenta la historia de cómo se hizo el instrumento.



FunctionTrace



Recientemente creé una herramienta para ayudar a los desarrolladores a comprender mejor lo que sucede en su código Python. FunctionTrace es un generador de perfiles sin muestreo para Python que se ejecuta en aplicaciones sin modificar con una sobrecarga muy baja: menos del 5%. Es importante señalar que está integrado con Firefox Profiler. Esto le permite interactuar gráficamente con los perfiles, lo que facilita la detección de patrones y la realización de cambios en su base de código.



Revisaré los objetivos de desarrollo de FunctionTrace y compartiré los detalles de implementación técnica. Al final jugaremos con una pequeña demo .





Un ejemplo de un perfil de FunctionTrace abierto en Firefox Profiler.



La deuda tecnológica como motivación



Las bases de código tienden a agrandarse con el tiempo. Especialmente cuando se trabaja en proyectos complejos con mucha gente. Algunos idiomas resuelven mejor este problema. Por ejemplo, las capacidades del IDE de Java existen desde hace décadas. O Rust y su fuerte mecanografía, lo que facilita la refactorización. A veces parece que a medida que crecen las bases de código en otros lenguajes, resulta más difícil de mantener. Esto es especialmente cierto para el código Python más antiguo. Al menos todos somos Python 3 en este momento, ¿verdad?



Hacer cambios a gran escala o refactorizar código desconocido puede ser extremadamente difícil. Es mucho más fácil para mí cambiar el código correctamente cuando veo todas las interacciones del programa y lo que hace. A menudo, incluso me encuentro reescribiendo fragmentos de código que nunca tuve la intención de tocar: la ineficiencia es obvia cuando la veo en la visualización.



Quería entender lo que estaba pasando en el código sin tener que leer cientos de archivos. Pero no encontré las herramientas que se ajustan a mis necesidades. Además, perdí el interés en construir una herramienta de este tipo debido a la cantidad de trabajo de interfaz de usuario involucrado. Y la interfaz era necesaria. Mis esperanzas de comprender rápidamente la ejecución del programa se reavivaron cuando me topé con el generador de perfiles de Firefox.



El generador de perfiles proporcionó todos los elementos difíciles de implementar: una interfaz de usuario intuitiva y de código abierto que muestra diagramas de pila, marcadores de registro con límite de tiempo, gráficos de incendios y brinda estabilidad, cuya naturaleza se vincula a un famoso navegador web. Cualquier herramienta que pueda escribir un perfil JSON con el formato adecuado puede reutilizar todas las capacidades de análisis gráfico mencionadas anteriormente.



Función de seguimiento de diseño



Afortunadamente, ya tenía una semana de vacaciones planeada después de descubrir el perfilador de Firefox. Y tenía un amigo que quería desarrollar un instrumento conmigo. También se tomó un día libre esa semana.



Objetivos



Teníamos varios objetivos cuando comenzamos a desarrollar FunctionTrace:



  1. La capacidad de ver todo lo que sucede en el programa.
  2. .
  3. , .


El primer objetivo tuvo un impacto significativo en el diseño. Los dos últimos agregaron complejidad de ingeniería. Ambos sabíamos por experiencias pasadas con herramientas similares que la frustración es que no veremos llamadas de función demasiado cortas. Cuando registra un registro de seguimiento de 1 ms, pero tiene funciones importantes y más rápidas, se está perdiendo mucho de lo que está sucediendo dentro de su programa.



También sabíamos que necesitábamos rastrear todas las llamadas a funciones. Por lo tanto, no pudimos utilizar el generador de perfiles de muestreo. Además, recientemente pasé algún tiempo con el código donde las funciones de Python ejecutan otro código de Python, a menudo a través de un script de intermediario de shell. En base a esto, queríamos poder rastrear los procesos secundarios.



Implementación inicial



Para soportar múltiples procesos y descendientes, nos decidimos por un modelo cliente-servidor. Los clientes de Python envían datos de seguimiento al servidor de Rust. El servidor agrega y comprime los datos antes de generar un perfil, que puede ser consumido por el generador de perfiles de Firefox. Elegimos Rust por varias razones, entre las que se incluyen la escritura sólida, el esfuerzo por lograr un rendimiento constante y un uso de memoria predecible, y la facilidad de creación de prototipos y refactorización.



Creamos el prototipo del cliente como un módulo de Python llamado python -m functiontrace code.py. Esto facilitó el uso de los ganchos de seguimiento integrados para registrar la ejecución. La implementación original se veía así:



def profile_func(frame, event, arg):
    if event == "call" or event == "return" or event == "c_call" or event == "c_return":
        data = (event, time.time())
        server.sendall(json.dumps(data))

sys.setprofile(profile_func)




El servidor está escuchando en un socket de dominio Unix . Luego, los datos se leen desde el cliente y el generador de perfiles de Firefox los convierte a JSON .



El generador de perfiles admite varios tipos de perfiles, como registros de rendimiento . Pero decidimos generar JSON del formato de perfilador interno. Requiere menos espacio y mantenimiento que agregar un nuevo formato compatible. Es importante tener en cuenta que el generador de perfiles mantiene la compatibilidad con versiones anteriores de los perfiles. Esto significa que cualquier perfil diseñado para la versión actual del formato se convierte automáticamente a la última versión cuando se descarga en el futuro. El generador de perfiles también se refiere a cadenas con identificadores enteros. Esto ahorra mucho espacio al usar la deduplicación (es trivial de usarindexmap ).



Varias optimizaciones



En su mayoría, el código original funcionó. En cada llamada y devolución de función, Python llamó al gancho. El gancho envió un mensaje JSON al servidor a través de un socket para convertirlo al formato deseado. Pero fue increíblemente lento. Incluso después de agrupar las llamadas de socket, vimos al menos ocho veces la sobrecarga de algunos programas de prueba.



Después de ver tales costos, bajamos al nivel C usando la API C para Python . Y obtuvieron un coeficiente de gastos generales de 1,1 en los mismos programas. Después de eso, pudimos realizar otra optimización clave, reemplazando las llamadas time.time()a las operaciones rdtsc a través declock_gettime()... Hemos reducido la sobrecarga de rendimiento de las funciones de llamada a varias instrucciones y la generación de 64 bits de datos. Esto es mucho más eficiente que encadenar llamadas de Python y aritmética compleja en una ruta de misión crítica.



Mencioné que se admite el seguimiento de múltiples subprocesos y procesos secundarios. Esta es una de las partes más difíciles del cliente, por lo que vale la pena discutir algunos detalles de nivel inferior.



Soporte para múltiples transmisiones



El controlador de todos los subprocesos se instala mediante threading.setprofile(). Nos registramos a través de un controlador como este cuando configuramos el estado del hilo. Esto asegura que Python se esté ejecutando y el GIL esté retenido. Esto simplifica algunas de las suposiciones:



// This is installed as the setprofile() handler for new threads by
// threading.setprofile().  On its first execution, it initializes tracing for
// the thread, including creating the thread state, before replacing itself with
// the normal Fprofile_FunctionTrace handler.
static PyObject* Fprofile_ThreadFunctionTrace(..args..) {
    Fprofile_CreateThreadState();

    // Replace our setprofile() handler with the real one, then manually call
    // it to ensure this call is recorded.
    PyEval_SetProfile(Fprofile_FunctionTrace);
    Fprofile_FunctionTrace(..args..);
    Py_RETURN_NONE;
}




Cuando se llama al gancho Fprofile_ThreadFunctionTrace(), asigna la estructura ThreadState. Esta estructura contiene la información que necesita el hilo para registrar eventos y comunicarse con el servidor. Luego enviamos un mensaje de inicio al servidor de perfiles. Aquí notificamos al servidor para que inicie una nueva transmisión y proporcionamos información inicial: hora, PID, etc. Después de la inicialización, reemplazamos el gancho con Fprofile_FunctionTrace()uno que realiza el seguimiento real.



Soporte para procesos secundarios



Cuando trabajamos con varios procesos, asumimos que los procesos secundarios se inician mediante el intérprete de Python. Desafortunadamente, los procesos secundarios no se llaman con -m functiontrace, por lo que no sabemos cómo rastrearlos. Para asegurarse de que se supervisen los procesos secundarios, la variable de entorno $ PATH se cambia al inicio . Esto asegura que Python apunte a un ejecutable que conozca functiontrace:



# Generate a temp directory to store our wrappers in.  We'll temporarily
# add this directory to our path.
tempdir = tempfile.mkdtemp(prefix="py-functiontrace")
os.environ["PATH"] = tempdir + os.pathsep + os.environ["PATH"]

# Generate wrappers for the various Python versions we support to ensure
# they're included in our PATH.
wrap_pythons = ["python", "python3", "python3.6", "python3.7", "python3.8"]
for python in wrap_pythons:
    with open(os.path.join(tempdir, python), "w") as f:
        f.write(PYTHON_TEMPLATE.format(python=python))
        os.chmod(f.name, 0o755)




Se -m functiontracellama a un intérprete con un argumento dentro del contenedor. Finalmente, se agrega una variable de entorno al inicio. La variable indica qué socket se utiliza para comunicarse con el servidor de perfiles. Si el cliente se inicializa y ve una variable de entorno ya establecida, reconoce el proceso hijo. Luego se conecta a la instancia del servidor existente, lo que permite que su seguimiento se correlacione con el del cliente original.



FunctionTrace ahora



La implementación de FunctionTrace hoy tiene mucho en común con la implementación descrita anteriormente. A un alto nivel el cliente se realiza un seguimiento a través de una llamada FunctionTrace como esto: python -m functiontrace code.py. Esta línea carga un módulo de Python para alguna personalización y luego llama al módulo C para configurar los diversos enlaces de seguimiento. Estos ganchos incluyen los sys.setprofileganchos de asignación de memoria mencionados anteriormente , así como los ganchos personalizados con funciones interesantes como builtins.printo builtins.__import__. Además, functiontrace-serverse genera una instancia , se configura un socket para comunicarse con ella y se garantiza que los subprocesos futuros y los procesos secundarios se comuniquen con el mismo servidor.



En cada evento de seguimiento, el cliente Python envía una entrada MessagePack... Contiene información mínima de eventos y una marca de tiempo en el búfer de memoria de flujo. Cuando el búfer está lleno (cada 128 KB), se descarga al servidor a través del socket compartido y el cliente continúa haciendo su trabajo. El servidor escucha a cada cliente de forma asincrónica, consumiendo rápidamente los rastros en un búfer separado para evitar bloquearlos. El hilo correspondiente a cada cliente puede analizar cada evento de seguimiento y convertirlo al formato final apropiado. Una vez que todos los clientes conectados salen, los registros de cada tema se combinan en un registro de perfil completo. Finalmente, todo esto se envía a un archivo, que luego se puede usar con el generador de perfiles de Firefox.



Lecciones aprendidas



Un módulo Python C proporciona una potencia y un rendimiento significativamente mayores, pero al mismo tiempo tiene un costo elevado. Se requiere más código, es más difícil encontrar una buena documentación y hay pocas funciones disponibles. Los módulos C parecen ser una herramienta infrautilizada para escribir módulos Python de alto rendimiento. Digo esto basándome en algunos de los perfiles de FunctionTrace que he visto. Recomendamos un equilibrio. Escriba la mayor parte del código de misión crítica que no funciona en Python y llame a los bucles internos o al código de configuración de C para las partes de su programa donde Python no brilla.



La codificación y decodificación JSON puede ser increíblemente lenta cuando no hay necesidad de legibilidad. Nos cambiamos aMessagePackpara la comunicación cliente-servidor y resultó igual de fácil trabajar con él, ¡reduciendo a la mitad el tiempo de algunos puntos de referencia!



Admitir perfiles de subprocesos múltiples en Python es bastante difícil. Es comprensible por qué no era una característica clave en los perfiladores de Python anteriores. Se necesitaron varios enfoques diferentes y muchos errores de segmentación antes de tener una buena idea de cómo trabajar con GIL manteniendo un alto rendimiento.



imagen


Puede obtener una profesión demandada desde cero o subir de nivel en habilidades y salario tomando cursos en línea de SkillFactory:





E







All Articles