Почему происходит накладные функции Haskell от C?

Я заметил значительные накладные расходы, вызывающие функции Haskell на C, намного большие, чем накладные расходы на вызов функции C. Чтобы отделить проблему от ее сути, я написал программу, которая просто инициализирует рабочую среду Haskell, запускает цикл, который вызывает пустую функцию 100 000 000 раз и возвращает.

С помощью встроенной функции программа принимает 0,003 секунды. Вызов пустой функции, написанной на C, занимает 0.18 с. Вызов пустой функции, написанной в Haskell, занимает 15,5 секунд. (Как ни странно, если я компилирую пустой файл Haskell отдельно перед связыванием, это занимает еще несколько секунд. Подзапрос: почему это?)

Итак, похоже, что около 100-кратного замедления между вызовом функции C и вызовом функции Haskell. В чем причина этого, и есть ли способ смягчить это замедление?

Код

EDIT: Я обнаружил версию этого теста в NoFib benchmark suite, callback002. Там хороший пост в блоге от Edward Z. Yang упоминает этот тест в контексте планировщика GHC. Я все еще пытаюсь вырвать это сообщение в блоге вместе с Zeta очень приятным ответом. Я еще не убежден, что этого не сделать быстрее!

Чтобы скомпилировать "медленную" версию Haskell, запустите

ghc -no-hs-main -O2 -optc-O3 test.c Test.hs -o test

Чтобы скомпилировать "быструю" версию C, запустите

ghc -no-hs-main -O2 -optc-O3 test.c test2.c TestDummy.hs -o test

test.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() {
}

Test.hs:

{-# LANGUAGE ForeignFunctionInterface #-}

module Test where

foreign export ccall test :: ()

test :: ()
test = ()

TestDummy.hs:

module Test where

Ответы

Ответ 1

TL; DR: Причина: вызовы RTS и STG. Решение: не вызывайте тривиальные функции Haskell из C.


В чем причина этого??

Отказ от ответственности: я никогда не называл Haskell от C. Я знаком с C и Haskell, но я редко переплетаю оба, если только я не пишу обертку. Теперь, когда я потерял доверие, позвольте начать это приключение бенчмаркинга, разборки и других отличных ужасов.

Бенчмаркинг с помощью gprof

Один простой способ проверить, что есть все ваши ресурсы, - использовать gprof. Мы немного изменим вашу строку компиляции, чтобы -pg использовался как компилятором, так и компоновщиком (примечание: я переименовал test.c в main.c и test2.c на test.c):

$ ghc -no-hs-main -O2 -optc-O3 -optc-pg -optl-pg -fforce-recomp \
    main.c Test.hs -o test
$ ./test
$ gprof ./test

Это дает нам следующий (плоский) профиль:

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, что куча вызываемых функций. Как это сравнить с вашей версией 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

Это лот проще. Но почему?

Что происходит за?

Возможно, вы задавались вопросом, почему test появился даже в предыдущем профиле. Ну, gprof сам добавляет некоторые накладные расходы, как видно из 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 <[email protected]>
  405639:   5d                      pop    %rbp
  40563a:   c3                      retq   

Вызов mcount добавляется gcc. Поэтому для следующей части вы хотите удалить опции -pg. Пусть сначала проверьте разобранную процедуру test в 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

repz retq на самом деле некоторая магия оптимизации, но в этом случае вы можете думать о ней как о возврате no-op (в основном).

Как это сравнить с версией 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

Это выглядит совсем по-другому. Действительно, функции RTS кажутся подозрительными. Посмотрим на них:

  • rts_checkSchedStatus просто проверяет, является ли состояние в порядке, и выходит иначе. Путь Success не имеет больших накладных расходов, поэтому эта функция не является виновником.
  • rts_unlock и rts_lock в основном заявляют и выпускают возможность (виртуальный процессор). Они называют newBoundTask и boundTaskExiting, что занимает некоторое время (см. Профиль выше).
  • rts_apply вызывает allocate, одну из наиболее часто используемых функций во всей программе (что не удивительно, поскольку Haskell собирает мусор).
  • rts_evalIO окончательно создает фактический поток и ждет его завершения. Таким образом, мы можем оценить, что только rts_evalIO занимает около 27%.

Итак, мы нашли все функции, которые занимают все время. STG и RTS берут на себя всю ответственность за накладные расходы в 150 нс за звонок.

... и есть ли способ смягчить это замедление?

Ну, ваш test в основном не-op. Вы называете это 100000000 раз, с общей продолжительностью 15 секунд. По сравнению с версией C это накладные расходы ~ 149 нс на звонок.

Решение довольно просто: не используйте функции Haskell в вашем мире C для тривиальных задач. Используйте правильный инструмент для правильной ситуации. В конце концов, вы не используете библиотеку GMP, если вам нужно добавить два числа, которые гарантированно будут меньше 10.

Помимо этого парадигматического решения: нет. Сборка, показанная выше, является тем, что создается GHC, и в настоящий момент невозможно создать вариант без вызовов RTS.