operadores - ¿Por qué hay una sobrecarga llamando a las funciones de Haskell desde C?
operadores logicos y aritmeticos en haskell (1)
He notado una sobrecarga significativa al llamar a las funciones de Haskell en C, mucho más grande que la sobrecarga de una llamada a la función C nativa. Para destilar el problema a su esencia, escribí un programa que simplemente inicializa el tiempo de ejecución de Haskell, ejecuta un ciclo que llama a una función vacía 100,000,000 veces y regresa.
Con la función en línea, el programa toma 0.003s. Llamar a una función vacía escrita en C toma 0.18s. Llamar a una función vacía escrita en Haskell toma 15.5s. (Extrañamente, si compilo el archivo Haskell vacío por separado antes de vincularlo, tomará unos segundos más. Subpregunta: ¿por qué es esto?)
Así que parece que hay aproximadamente una ralentización de 100 veces entre llamar a una función C y llamar a una función Haskell. ¿Cuál es la razón de esto, y hay una manera de mitigar esta desaceleración?
Código
EDITAR: He descubierto una versión de esta prueba en la suite de referencia NoFib ,
callback002
. Hay una buena publicación en el blog de Edward Z. Yang que menciona esta prueba en el contexto del programador de GHC. Todavía estoy tratando de asimilar esta publicación de blog junto con la muy buena respuesta de Zeta. ¡Todavía no estoy convencido de que no haya una manera de hacerlo más rápido!
Para compilar la versión "lenta" de Haskell, ejecute
ghc -no-hs-main -O2 -optc-O3 test.c Test.hs -o test
Para compilar la versión C "rápida", ejecute
ghc -no-hs-main -O2 -optc-O3 test.c test2.c TestDummy.hs -o test
prueba.c:
#include "HsFFI.h"
extern void __stginit_Test(void);
extern void test();
int main(int argc, char *argv[]) {
hs_init(&argc, &argv);
hs_add_root(__stginit_Test);
int i;
for (i = 0; i < 100000000; i++) {
test();
}
hs_exit();
return 0;
}
test2.c:
void test() {
}
Prueba.hs:
{-# LANGUAGE ForeignFunctionInterface #-}
module Test where
foreign export ccall test :: ()
test :: ()
test = ()
TestDummy.hs:
module Test where
TL; DR : Motivo: RTS y llamadas STG. Solución: no llamar a las funciones triviales de Haskell desde C.
Cuál es la razón para esto…?
Descargo de responsabilidad: nunca he llamado a Haskell desde C. Estoy familiarizado con C y Haskell, pero rara vez entrelazo ambos, a menos que esté escribiendo un envoltorio. Ahora que he perdido mi credibilidad, comencemos esta aventura de evaluación comparativa, desmontaje y otros horrores ingeniosos.
Benchmarking con gprof
Una forma fácil de verificar qué está consumiendo todos sus recursos es usar gprof. Cambiaremos su línea de compilación de forma que el compilador y el vinculador utilicen -pg (nota: he cambiado el nombre de test.c a main.c y test2.c a test.c):
$ ghc -no-hs-main -O2 -optc-O3 -optc-pg -optl-pg -fforce-recomp /
main.c Test.hs -o test
$ ./test
$ gprof ./test
Esto nos da el siguiente perfil (plano):
Flat profile: Each sample counts as 0.01 seconds. % cumulative self self total time seconds seconds calls Ts/call Ts/call name 16.85 2.15 2.15 scheduleWaitThread 11.78 3.65 1.50 createStrictIOThread 7.66 4.62 0.98 createThread 6.68 5.47 0.85 allocate 5.66 6.19 0.72 traverseWeakPtrList 5.34 6.87 0.68 isAlive 4.12 7.40 0.53 newBoundTask 3.06 7.79 0.39 stg_ap_p_fast 2.36 8.09 0.30 stg_ap_v_info 1.96 8.34 0.25 stg_ap_0_fast 1.85 8.57 0.24 rts_checkSchedStatus 1.81 8.80 0.23 stg_PAP_apply 1.73 9.02 0.22 rts_apply 1.73 9.24 0.22 stg_enter_info 1.65 9.45 0.21 stg_stop_thread_info 1.61 9.66 0.21 test 1.49 9.85 0.19 stg_returnToStackTop 1.49 10.04 0.19 move_STACK 1.49 10.23 0.19 stg_ap_v_fast 1.41 10.41 0.18 rts_lock 1.18 10.56 0.15 boundTaskExiting 1.10 10.70 0.14 StgRun 0.98 10.82 0.13 rts_evalIO 0.94 10.94 0.12 stg_upd_frame_info 0.79 11.04 0.10 blockedThrowTo 0.67 11.13 0.09 StgReturn 0.63 11.21 0.08 createIOThread 0.63 11.29 0.08 stg_bh_upd_frame_info 0.63 11.37 0.08 c5KU_info 0.55 11.44 0.07 stg_stk_save_n 0.51 11.50 0.07 threadPaused 0.47 11.56 0.06 dirty_TSO 0.47 11.62 0.06 ghczmprim_GHCziCString_unpackCStringzh_info 0.47 11.68 0.06 stopHeapProfTimer 0.39 11.73 0.05 stg_threadFinished 0.39 11.78 0.05 allocGroup 0.39 11.83 0.05 base_GHCziTopHandler_runNonIO1_info 0.39 11.88 0.05 stg_catchzh 0.35 11.93 0.05 freeMyTask 0.35 11.97 0.05 rts_eval_ 0.31 12.01 0.04 awakenBlockedExceptionQueue 0.31 12.05 0.04 stg_ap_2_upd_info 0.27 12.09 0.04 s5q4_info 0.24 12.12 0.03 markStableTables 0.24 12.15 0.03 rts_getSchedStatus 0.24 12.18 0.03 s5q3_info 0.24 12.21 0.03 scavenge_stack 0.24 12.24 0.03 stg_ap_7_upd_info 0.24 12.27 0.03 stg_ap_n_fast 0.24 12.30 0.03 stg_gc_noregs 0.20 12.32 0.03 base_GHCziTopHandler_runIO1_info 0.20 12.35 0.03 stat_exit 0.16 12.37 0.02 GarbageCollect 0.16 12.39 0.02 dirty_STACK 0.16 12.41 0.02 freeGcThreads 0.16 12.43 0.02 rts_mkString 0.16 12.45 0.02 scavenge_capability_mut_lists 0.16 12.47 0.02 startProfTimer 0.16 12.49 0.02 stg_PAP_info 0.16 12.51 0.02 stg_ap_stk_p 0.16 12.53 0.02 stg_catch_info 0.16 12.55 0.02 stg_killMyself 0.16 12.57 0.02 stg_marked_upd_frame_info 0.12 12.58 0.02 interruptAllCapabilities 0.12 12.60 0.02 scheduleThreadOn 0.12 12.61 0.02 waitForReturnCapability 0.08 12.62 0.01 exitStorage 0.08 12.63 0.01 freeWSDeque 0.08 12.64 0.01 gcStableTables 0.08 12.65 0.01 resetTerminalSettings 0.08 12.66 0.01 resizeNurseriesEach 0.08 12.67 0.01 scavenge_loop 0.08 12.68 0.01 split_free_block 0.08 12.69 0.01 startHeapProfTimer 0.08 12.70 0.01 stg_MVAR_TSO_QUEUE_info 0.08 12.71 0.01 stg_forceIO_info 0.08 12.72 0.01 zero_static_object_list 0.04 12.73 0.01 frame_dummy 0.04 12.73 0.01 rts_evalLazyIO_ 0.00 12.73 0.00 1 0.00 0.00 stginit_export_Test_zdfstableZZC0ZZCmainZZCTestZZCtest
Woah, eso es un montón de funciones que se llaman. ¿Cómo se compara esto con tu versión C?
$ ghc -no-hs-main -O2 -optc-O3 -optc-pg -optl-pg -fforce-recomp /
main.c TestDummy.hs test.c -o test_c
$ ./test_c
$ gprof ./test_c
Flat profile: Each sample counts as 0.01 seconds. % cumulative self self total time seconds seconds calls Ts/call Ts/call name 75.00 0.05 0.05 test 25.00 0.06 0.02 frame_dummy
Eso es mucho más simple. ¿Pero por qué?
¿Qué está pasando detrás?
Tal vez te has preguntado por qué la test
incluso apareció en el perfil anterior. Bueno, gprof agrega cierta sobrecarga, como puede verse con objdump
:
$ objdump -D ./test_c | grep -A5 "<test>:"
0000000000405630 <test>:
405630: 55 push %rbp
405631: 48 89 e5 mov %rsp,%rbp
405634: e8 f7 d4 ff ff callq 402b30 <mcount@plt>
405639: 5d pop %rbp
40563a: c3 retq
La llamada de mcount
es agregada por gcc. Así que para la siguiente parte desea eliminar las opciones de -pg
. Revisemos primero la rutina de test
desmontada en C:
$ ghc -no-hs-main -O2 -optc-O3 -fforce-recomp /
main.c TestDummy.hs test.c -o test_c
$ objdump -D ./test_c | grep -A2 "<test>:"
0000000000405510 <test>:
405510: f3 c3 repz retq
El repz retq
es en realidad una magia de optimización , pero en este caso, puedes pensar que se trata de un retorno (en su mayoría) no operativo.
¿Cómo se compara esto con la versión de Haskell?
$ ghc -no-hs-main -O2 -optc-O3 -fforce-recomp /
main.c Test.hs -o test_hs
$ objdump -D ./Test.o | grep -A18 "<test>:"
0000000000405520 <test>:
405520: 48 83 ec 18 sub $0x18,%rsp
405524: e8 f7 3a 05 00 callq 459020 <rts_lock>
405529: ba 58 24 6b 00 mov $0x6b2458,%edx
40552e: be 80 28 6b 00 mov $0x6b2880,%esi
405533: 48 89 c7 mov %rax,%rdi
405536: 48 89 04 24 mov %rax,(%rsp)
40553a: e8 51 36 05 00 callq 458b90 <rts_apply>
40553f: 48 8d 54 24 08 lea 0x8(%rsp),%rdx
405544: 48 89 c6 mov %rax,%rsi
405547: 48 89 e7 mov %rsp,%rdi
40554a: e8 01 39 05 00 callq 458e50 <rts_evalIO>
40554f: 48 8b 34 24 mov (%rsp),%rsi
405553: bf 64 57 48 00 mov $0x485764,%edi
405558: e8 23 3a 05 00 callq 458f80 <rts_checkSchedStatus>
40555d: 48 8b 3c 24 mov (%rsp),%rdi
405561: e8 0a 3b 05 00 callq 459070 <rts_unlock>
405566: 48 83 c4 18 add $0x18,%rsp
40556a: c3 retq
40556b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
405570: d8 ce fmul %st(6),%st
Esto se ve bastante diferente. De hecho, las funciones de RTS parecen sospechosas. Echemos un vistazo a ellos:
-
rts_checkSchedStatus
simplemente comprueba si el estado esrts_checkSchedStatus
y sale de lo contrario. La ruta delSuccess
no tiene mucha sobrecarga, por lo que esta función no es realmente la culpable. -
rts_unlock
yrts_lock
básicamente reclaman y liberan una capability (una CPU virtual). Llaman anewBoundTask
yboundTaskExiting
, lo que lleva algún tiempo (ver perfil arriba). -
rts_apply
llamadasrts_apply
seallocate
, una de las funciones más utilizadas en todo el programa (lo que no es realmente sorprendente, ya que se recolecta la basura de Haskell). -
rts_evalIO
finalmente crea el hilo real y espera su finalización. Así que podemos estimar que solorts_evalIO
toma alrededor del 27%.
Así que encontramos todas las funciones que están tomando todo el tiempo. El STG y el RTS se llevan todo el crédito por los gastos generales de 150 ns por llamada.
... y hay una manera de mitigar esta desaceleración?
Bueno, tu test
es básicamente un no-op. Lo estás llamando 100000000 veces, con un tiempo de ejecución total de 15s. En comparación con la versión C, es una sobrecarga de ~ 149ns por llamada.
La solución es bastante simple: no utilice las funciones de Haskell en su mundo C para tareas triviales. Utilice la herramienta adecuada para la situación correcta. Después de todo, no usa la biblioteca GMP si necesita agregar dos números que se garantiza que sean menos de 10.
Aparte de esta solución paradigmática: no. El ensamblaje que se muestra arriba es lo que creó GHC, y no es posible crear una variante sin llamadas RTS en este momento.