← Архив: Common Lisp

Параллельные вычисления с float числами

Author: · 09.03.2012 10:35
· original author: sviridov
Есть например такой код:
(loop repeat 10
        do (time
            (let ((x 0))
              (dotimes (i 250000000)
                (incf x 1.0)
)
)
)
)

(loop repeat 10
       do (time
           (let* ((x 0)
                  (lock (make-lock))
                  (th1 (make-thread (lambda ()
                                      (let ((s 0))
                                        (dotimes (i 83333333)
                                          (incf s 1.0)
)

                                        (with-lock-held (lock)
                                          (incf x s)
)
)
)
)
)

                  (th2 (make-thread (lambda ()
                                      (let ((s 0))
                                        (dotimes (i 83333333)
                                          (incf s 1.0)
)

                                        (with-lock-held (lock)
                                          (incf x s)
)
)
)
)
)
)

             (let ((s 0))
               (dotimes (i 83333334)
                 (incf s 1.0)
)

               (with-lock-held (lock)
                 (incf x s)
)
)

             (join-thread th1)
             (join-thread th2)
)
)
)
)
Однопоточная версия выполняется за одно и тоже время. А вот в 3 потока первая итерация:
Evaluation took:
  6.108 seconds of real time
  10.412651 seconds of total run time (10.200638 user, 0.212013 system)
  [ Run times consist of 3.332 seconds GC time, and 7.081 seconds non-GC time. ]
  170.48% CPU
  12,887,838,719 processor cycles
  1,998,156,624 bytes consed
А последняя:
Evaluation took:
  9.235 seconds of real time
  13.556848 seconds of total run time (13.304832 user, 0.252016 system)
  [ Run times consist of 6.408 seconds GC time, and 7.149 seconds non-GC time. ]
  146.80% CPU
  19,482,702,363 processor cycles
  1,997,795,272 bytes consed
Т.е. с каждой итерацией время работы GC растёт! Почему? И почему оно вообще столь велико?
Что любопытно, версия с инкрементом на 1, а не на 1.0 лишена этой проблемы.
Пробовал  на sbcl-1.0.55 x86 под Linux на трёх ядерной машине.
В ccl тоже не все хорошо: время работы GC слишком большое (но хоть не растёт).
· original author: dsorokin
Боксинг как особенность реализации вещественных чисел?
* (eq 1.0 1.0) NIL
· original author: dmitry.sopin
Ну, видимо память "забивается" со временем. Поэтому и время уборки растет. Обрати внимание, cons-ится при этом примерно одинаково.
· original author: Love5an
Боксинг.
Вообще, первая причина тормозов в лиспе это лишние выделения памяти, которые в.т.ч и из-за боксинга возникают.
Поэтому в программах, требующих производительности, их следует избегать всеми силами.
· original author: sviridov
Я правильно понимаю, что этот пример вряд ли сделаешь быстрее?
· original author: Love5an
(locally
  (declare (optimize (speed 3) (space 3) (safety 0) (debug 0)))
  (loop :repeat 10 :do
    (time
      (let* ((x (make-array 1
                  :element-type 'single-float
                  :initial-element 0.0
)
)

             (lock (make-lock))
             (th1 (make-thread (lambda ()
                                 (let ((s 0.0))
                                   (dotimes (i 83333333)
                                     (incf s 1.0)
)

                                   (with-lock-held (lock)
                                     (incf (aref x 0) s)
                                     (values)
)
)
)
)
)

             (th2 (make-thread (lambda ()
                                 (let ((s 0.0))
                                   (dotimes (i 83333333)
                                     (incf s 1.0)
)

                                   (with-lock-held (lock)
                                     (incf (aref x 0) s)
                                     (values)
)
)
)
)
)
)

        (let ((s 0.0))
          (dotimes (i 83333334)
            (incf s 1.0)
)

          (with-lock-held (lock)
            (incf (aref x 0) s)
            (values)
)
)

        (join-thread th1)
        (join-thread th2)
)
)
)
)
· original author: Love5an
Кстати, из-за того что числа большие, single-float результат выходит не точный.с double-float'ами не сильно медленнее, но результат получается верный:
http://paste.lisp.org/display/128256 
· original author: Love5an
но кстати, это я пошел совсем экстремальным путем, и убрал вообще весь боксинг
На самом деле, так как к 'x' обращение идет всего считай три раза(ну четыре, если считать вывод на stdout у меня), то ее боксинг не играет такой уж существенной роли. Вообще практически роли не играет, можно сказать.
http://paste.lisp.org/display/128257 
Вот тут она боксится, и даже не просто в массив, а в value-cell(насколько я понимаю SBCL), и там даже будет псевдоатомарная операция, вроде бы, но всего четыре обращения никакого оверхеда не добавят.
Почему тормозит изначальная версия?
Во-первых, там идет обобщенная арифметика - значения s - изначально целые числа. Они не конвертируются во флоаты при старте(как ты, возможно, подумал, проводя аналогию с языками типа Си) - так как типизация динамическая; и опять же, так как типизация динамическая, и деклараций типов не проставлено, SBCL вынужден относится к 's' как к переменных, хранящим "любые числа"(класс Number. Кстати, SBCL все-же проводит некоторый вывод типов, как это можно лицезреть из лога компиляции с декларациями оптимизации - т.е. он считает 's' именно переменными с числами, и не просто с объектами), и соответственно, применять к ним обобщенную арифметику, обобщенное присваивание и так далее. В моем же варианте, когда я всего-то изначально сделал s флоатами, SBCL распихивает их по регистрам и использует процессорное FPU, прямо как Си и другие низкоуровневые языки.
Во-вторых, не проставлены декларации оптимизации - и SBCL вставляет в код кучу разнообразных проверок и вызовов фукнкций для дебага - а это вносит невероятно сильный оверхед.
· original author: Love5an
Кстати, и еще такой момент - почему работа GC так сильно влияет на производительность в многопоточном режиме.Потому, что, хотя выделение памяти в SBCL идет более-менее параллельно, сборщик мусора, при очистке памяти, вынужден проводить множество работы по синхронизации. В частности, сам процесс сборки мусора в SBCL работает по механизму "Stop The World", т.е. он SBCL останавливает все треды, освобождает память и потом продолжает работу, и это все вносит немаленький оверхед, когда тредов много, и они требуют много работы связанной с выделением/освобождением памяти.
· original author: sviridov
Ну вроде все понятно) Спасибо!
· original author: Love5an
кстати, вот я как-то писал статью про декларации типов, и немного про оптимизацию http://love5an.livejournal.com/357147.html 
· original author: orivej
> (space 3)
Какая польза от (optimize space)?  Это же означает запрет инлайнов!
· original author: Love5an
С чего бы запрет инлайнов?
(declaim (optimize (speed 3) (space 3) (safety 0) (debug 0)))
(declaim (inline foo))
(defun foo (x y)
  (+ x y)
)

(defun bar (a b)
  (foo a b)
)

; disassembly for BAR
; 2548A038:       840500000021     TEST AL, [#x21000000]      ; no-arg-parsing entry point
;       3E:       E85561B7FC       CALL #x22000198            ; GENERIC-+
;       43:       7302             JNB L0
;       45:       8BE3             MOV ESP, EBX
;       47: L0:   8BE5             MOV ESP, EBP
;       49:       F8               CLC
;       4A:       5D               POP EBP
;       4B:       C3               RET
Запрет инлайнов это notinline.
space это оптимизации по памяти - например, высокие ее значения позволяют компилятору размещать объекты на стеке(хотя конкретно в этом случае высокие значения speed тоже это разрешают)
· original author: orivej
Я пока не нашёл подходящего примера, разве что (declaim (sb-c::maybe-inline foo)) при (space 0) разрешается в inline, а при остальных space — нет.  Ещё в sb-md5 везде расставлено (optimize (speed 3) (safety 0) (space 0) (debug 0)).  Про связь inline со space сказано в документации sbcl, раздел 4.3 Compiler Policy.
· original author: Love5an
почитал - ну, space, возможно, в основном влияет на какие-то внутренние механизмы реалиазции инлайна, то как инлайнятся рекурсивные функции, наверное и т.п.
там говорится еще про операции - я так понимаю, про арифметику например - но при высоких speed арифметика всегда инлайнится, при возможности(т.е. когда типы известны и т.п.).
но вообще, если не брать чисто sbcl-евские maybe-inline например, то глобально определенные пользователем функции компилятор всегда инлайнит, если указана декларация inline, и никогда не инлайнит если она не указана. inline в то же время можно перекрыть через notinline. на локальные функции имхо space тоже мало влияет - они инлайнятся при высоких speed.
· original author: Love5an
Но точно что знаю - так это то, что space 100% относится к dynamic-extent - при высоких ее значениях, аллокация идет в стек(по возможности, опять же, естественно - а на это и другие вещи, кроме деклараций, влияют)
· original author: orivej
> при высоких speed арифметика всегда инлайнится, при возможности
Ну да; там в закоменченной части документации (которая от CMU CL) сказано: Inline expansion is mostly inhibited when space is greater than speed; так что при высоких speed высокий space, в том, что касается inline, по крайней мере раньше не играл роли.
> space 100% относится к dynamic-extent
Было бы хорошо посмотреть пример зависимости между space и dynamic-extent.  Чтобы проверить, как на него влияют разные speed и space.
· original author: Love5an
Хмм.. Я раньше точно видел что space влияет желание компилятора складывать что-либо на стек. но сейчас не написать найти пример, где бы он не складывал.Возможно, внутри SBCL что-то поменялось за это время. Ну, а может нужен просто достаточно сложный пример.
Но в старом мануале SBCL про это написано:
http://www.sbcl.org/1.0/manual/Dynamic_002dextent-allocation.html 
В новом уже, правда, не упоминается:
http://www.sbcl.org/manual/Dynamic_002dextent-allocation.html 
· original author: Love5an
Кстати, на производительность примера еще влияют локи.Тут они не нужны, на самом деле, т.к. обращение к 'x' будет псевдо-атомарное.
Вот я без них переписал - и тут(правда для 2 процессоров/ядер) при запуске четко видно ускорение ровно в количество тредов:
http://paste.lisp.org/display/128268 
· original author: dmitry.sopin
Локи как раз таки нужны. Правда на 2 потоках может и не проявиться.Пробовал такой код на четырехядерной машине: 
(let ((x 0)
(threads ()))
  (flet ((thread-function ()
              (dotimes (i 10000)
          (incf x 1.0))))
      (dotimes (i 10)
  (push (sb-thread:make-thread #'thread-function) threads))
      (mapc #'sb-thread:join-thread threads)
     x))
результат получается где-то в районе 80000.
· original author: Love5an
да, тут по-разному получается.я почему-то думал, он межпотоковое присваивание к value-cell делает атомарным.
но, если привести пример к виду, в котором присваивание общей переменной на каждый тред одно, то вероятность бага довольно небольшая.
· original author: Love5an
но с локами оверхед какой-то уж слишком большой.т.е. если делать как у меня в первом примере - то однопоточный пример из ОП выполняется быстрее чем распараллеливание на 2, например, треда, где-то на одну треть(на 2я машине).
хотя, если проверять вот так, то да, прирост получается кратным количеству ядер:
(defun test (&optional (n 1) (count 3))
  (declare (type (integer 0 #.most-positive-fixnum) n count)
           (optimize (speed 3) (safety 0) (debug 0))
)

  (loop :repeat count :do
    (time
      (let* ((tn (floor 250000000 n))
             (x 0.0d0)
             (lock (make-lock))
             (f (lambda ()
                  (let ((s 0.0d0))
                    (dotimes (i tn)
                      (incf s 1.0d0)
)

                    (with-lock-held (lock)
                      (incf x s)
                      (values)
)
)
)
)

             (threads '())
)

        (declare (type double-float x))
        (dotimes (i n)
          (push (make-thread f) threads)
)

        (mapcan #'join-thread threads)
        (print x)
)
)
)
)
· original author: dmitry.sopin
А за счет чего прирост должен быть? Инкременты все фактически последовательные (т.к. в критической секции) + оверхэд на лок.
· original author: Love5an
Всмысле, за счет чего? Инкременты идут в отдельных тредах, параллельно.
               (lambda ()
                  (let ((s 0.0d0))
                    (dotimes (i tn)
                      (incf s 1.0d0)
)

                    (with-lock-held (lock)
                      (incf x s)
                      (values)
)
)
)