Подтвердить что ты не робот

Сожмите большую скорость от Common Lisp/SBCL

В этой статье утверждается, что определенная программа Lisp работает быстрее, чем ее C эквивалент. Пытаясь воспроизвести результаты, мне удалось приблизиться (Lisp 50% медленнее, чем C), но хотел знать, знает ли кто-нибудь способы сжать больше perf из SBCL 1.3.1.

Задача заключается в добавлении постоянного одиночного поплавка в каждую ячейку в 800 x 800 одиночных поплавков. Метод состоит в том, чтобы написать программу на C и в Общий Lisp и сравните время. Используя этот портативный таймер, код C как следующим образом:

#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <time.h>

#include "./modules/tictoc/tictoc.h"

const int HORZ = 800;
const int VERT = 800;

#define PERF_REPS 1000

typedef float DATA_T;

struct image_s {
    size_t n;
    size_t w, h;
    DATA_T * data;
};
typedef struct image_s image;

image * make_image (size_t w, size_t h) {
    size_t n = w * h;
    DATA_T * data = (DATA_T *)malloc(sizeof(DATA_T) * n);
    assert (NULL != data);
    image * result = (image *)malloc(sizeof(image));
    assert (NULL != result);
    result->n = n;
    result->w = w;
    result->h = h;
    result->data = data;
    return result;
}

void free_image (image * it) {
    assert (NULL != it);
    assert (NULL != it->data);
    free (it->data);
    free (it);
}

image * init_to_value (image * it, DATA_T val) {
    assert (NULL != it);
    assert (NULL != it->data);
    size_t i;
    const size_t n = it->n;
    for (i = 0; i < n; ++i) {
        it->data[i] = val;
    }
    return it;
}

void add (image * to, image * from, DATA_T val) {
    assert (NULL != to);
    assert (NULL != to->data);
    assert (NULL != from);
    assert (NULL != from->data);
    size_t i;
    const size_t n = to->n;
    for (i = 0; i < n; ++i) {
        to->data[i] = from->data[i] + val;
    }
}

int main (int argc, char ** argv) {
    image * from = init_to_value (make_image (HORZ, VERT), 0.0f);
    image * to = init_to_value (make_image (HORZ, VERT), 0.0f);
    TicTocTimer clock = tic();
    for (size_t i = 0; i < PERF_REPS; ++i)
        add (to, from, 42.0);
    printf("Elapsed time %f seconds.\n",toc(&clock));
    free_image (to);
    free_image (from);
    return 0;
}

Я компилирую и запускаю код следующим образом:

gcc -O3 image-add.c ./modules/tictoc/libtictoc.a && ./a.out

Типичное время на моей программе mac book составляет около 0,178 секунды. Довольно приятно.

Эквивалентный код Lisp, используя каждую опцию, которую я смог найти в Lisp hyperspec, в новой книге Общие Lisp Рецепты, а в Руководство пользователя SBCL, является следующим образом. В комментариях указываются некоторые вещи, которые я пробовал, которые не разница.

;;; None of the commented-out declarations made any difference in speed. 

(declaim (optimize speed (safety 0)))

(defun image-add (to from val)
  (declare (type (simple-array single-float (*))
                 to from))
  (declare (type single-float val))
  (let ((size (array-dimension to 0)))
    ;(declare (type fixnum size))
    (dotimes (i size)
      ;(declare (type fixnum i))
      (setf (aref to i) (+ (aref from i) val)))))

(defparameter HORZ 800)
(defparameter VERT 800)

(defparameter PERF-REPS 1000)

(let ((to (make-array (* HORZ VERT) :element-type 'single-float))
      (fm (make-array (* HORZ VERT) :element-type 'single-float)))
  ;(declare (type fixnum HORZ))
  ;(declare (type fixnum VERT))
  (time (dotimes (_ PERF-REPS)
          ;(declare (type fixnum PERF-REPS))
          ;(declare (type fixnum _))
          ;(declare (inline image-add))
          (image-add to fm 42.0))))

Я компилирую и запускаю его следующим образом:

sbcl --script image-perf.lisp

а типичное время выполнения - 0.276. Неплохо, но я хочу, чтобы это было намного лучше. Конечно, весь смысл упражнения заключается в том, что код Lisp короче, но делает кто-нибудь знает способ сделать это так быстро или быстрее?

4b9b3361

Ответ 1

Для справки, вот некоторые результаты со слегка измененной версией.

версия C

В версии C в среднем 0.197s.

Lisp версия

(declaim (optimize (speed 3) (debug 0) (safety 0)))

(defconstant HORZ 800)
(defconstant VERT 800)
(defconstant PERF-REPS 1000)

(defun test ()
  (let ((target #1=(make-array (* HORZ VERT)
                               :element-type 'single-float
                               :initial-element 0f0))
        (source #1#))
    (declare (type (simple-array single-float (*)) target source))
    (time 
      (dotimes (_ PERF-REPS)
        (map-into target
                  (lambda (x)
                    (declare (single-float x))
                    (the single-float (+ x 42f0)))
                  source)))))

Вот результат:

Evaluation took:                                                                                                 
  0.372 seconds of real time                                                                                     
  0.372024 seconds of total run time (0.368023 user, 0.004001 system)                                            
  100.00% CPU                                                                                                    
  965,075,988 processor cycles                                                                                   
  0 bytes consed  

Заменяя map-into на lparallel:pmap-into, самое короткое время получается с ядром, состоящим из 4 рабочих, и дает:

Evaluation took:                                                                                                 
 0.122 seconds of real time                                                                                     
 0.496031 seconds of total run time (0.492030 user, 0.004001 system)                                            
 406.56% CPU                                                                                                    
 316,445,789 processor cycles                                                                                   
 753,280 bytes consed

Обратите внимание на разницу в использовании памяти.

Ответ 2

SBCL предоставляет кучу информации

Когда я сохранил ваш код (и завернул окончательный let пример в отдельную функцию) и скомпилировал его с помощью SBCL, я действительно получаю кучу диагностического вывода, который информирует нас о некоторых местах где может быть создан более эффективный код. Там много, но проскальзывают сюда, хотя все это в тесте, поэтому оно может быть или не быть полезным. Но, поскольку тестовый код может замедлить работу, стоит сделать снимок.

CL-USER> (compile-file ".../compile.lisp")
; compiling file ".../compile.lisp" (written 25 JAN 2016 01:53:23 PM):
; compiling (DECLAIM (OPTIMIZE SPEED ...))
; compiling (DEFUN IMAGE-ADD ...)
; compiling (DEFPARAMETER HORZ ...)
; compiling (DEFPARAMETER VERT ...)
; compiling (DEFPARAMETER PERF-REPS ...)
; compiling (DEFUN TEST ...)

; file: /home/taylorj/tmp/compile.lisp
; in: DEFUN TEST
;     (* HORZ VERT)
; 
; note: unable to
;   convert x*2^k to shift
; due to type uncertainty:
;   The first argument is a NUMBER, not a INTEGER.
;   The second argument is a NUMBER, not a INTEGER.
; 
; note: unable to
;   convert x*2^k to shift
; due to type uncertainty:
;   The first argument is a NUMBER, not a INTEGER.
;   The second argument is a NUMBER, not a INTEGER.

;     (DOTIMES (_ PERF-REPS) (IMAGE-ADD TO FM 42.0))
; --> DO BLOCK LET TAGBODY UNLESS IF >= IF 
; ==>
;   (< SB-C::X SB-C::Y)
; 
; note: forced to do GENERIC-< (cost 10)
;       unable to do inline fixnum comparison (cost 4) because:
;       The first argument is a UNSIGNED-BYTE, not a FIXNUM.
;       The second argument is a INTEGER, not a FIXNUM.

; --> DO BLOCK LET TAGBODY PSETQ PSETF LET* MULTIPLE-VALUE-BIND LET 1+ 
; ==>
;   (+ _ 1)
; 
; note: forced to do GENERIC-+ (cost 10)
;       unable to do inline fixnum arithmetic (cost 1) because:
;       The first argument is a UNSIGNED-BYTE, not a FIXNUM.
;       The result is a (VALUES (INTEGER 1) &OPTIONAL), not a (VALUES FIXNUM
;                                                                     &REST T).
;       unable to do inline fixnum arithmetic (cost 2) because:
;       The first argument is a UNSIGNED-BYTE, not a FIXNUM.
;       The result is a (VALUES (INTEGER 1) &OPTIONAL), not a (VALUES FIXNUM
;                                                                     &REST T).
;       etc.

;     (* HORZ VERT)
; 
; note: forced to do GENERIC-* (cost 30)
;       unable to do inline fixnum arithmetic (cost 4) because:
;       The first argument is a NUMBER, not a FIXNUM.
;       The second argument is a NUMBER, not a FIXNUM.
;       unable to do inline (signed-byte 64) arithmetic (cost 5) because:
;       The first argument is a NUMBER, not a (SIGNED-BYTE 64).
;       The second argument is a NUMBER, not a (SIGNED-BYTE 64).
;       etc.
; 
; note: forced to do GENERIC-* (cost 30)
;       unable to do inline fixnum arithmetic (cost 4) because:
;       The first argument is a NUMBER, not a FIXNUM.
;       The second argument is a NUMBER, not a FIXNUM.
;       unable to do inline (signed-byte 64) arithmetic (cost 5) because:
;       The first argument is a NUMBER, not a (SIGNED-BYTE 64).
;       The second argument is a NUMBER, not a (SIGNED-BYTE 64).
;       etc.
; 
; compilation unit finished
;   printed 6 notes

; .../compile.fasl written
; compilation finished in 0:00:00.009

Этот материал был в тесте, но поскольку вы выбираете цикл dotimes, это может иметь смысл, по крайней мере, исправить это сравнение. Здесь test код с объявлениями, которые должны сделать dotimes немного быстрее:

(defun test ()
  (declare (type fixnum HORZ VERT PERF-REPS))
  (let ((to (make-array (* HORZ VERT) :element-type 'single-float))
        (fm (make-array (* HORZ VERT) :element-type 'single-float)))
    (time (dotimes (_ PERF-REPS)
            (image-add to fm 42.0)))))

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

Стандартный ответ "обязательно используйте оптимизацию и безопасность"

Изменить: стрелять, я пропустил верхний уровень, объявив, что делает опорную скорость и безопасность. По-прежнему стоит проверить, имеют ли они желаемый эффект, но если они есть, то этот ответ в основном избыточный.

По большей части компилятору разрешено полностью игнорировать объявления. (Единственное исключение - объявления специальные, которые изменяют семантику привязки для переменной.) Итак, что делает с ними компилятор. Объявление типа может использоваться как минимум двумя разными способами. Если вы пытаетесь скомпилировать код безопасный, введите объявления, чтобы компилятор знал, что дополнительные проверки могут быть добавлены. Конечно, это приведет к более медленному коду, но это будет безопаснее. С другой стороны, если вы пытаетесь сгенерировать очень быстрый код, тогда компилятор может принять эти объявления типа в качестве гарантии того, что значения всегда будут иметь правильный тип и, таким образом, будут генерировать более быстрый код,

Похоже, вы добавляете объявления типов. Если вам нужен более быстрый (или безопасный) код, вы захотите добавить объявления, чтобы сказать это тоже. В этом случае вам, вероятно, понадобится (объявить (оптимизировать (скорость 3) (безопасность 0))). Например, взгляните несколько разборки простой функции, которая возвращает свой аргумент fixnum. Во-первых, просто объявляя тип, код заканчивается 18 байтами:

(defun int-identity (x)
  (declare (type fixnum x))
  x)

INT-IDENTITY
CL-USER> (disassemble 'int-identity)
; disassembly for INT-IDENTITY
; Size: 18 bytes. Origin: #x100470619A
; 9A:       488BE5           MOV RSP, RBP                     ; no-arg-parsing entry point
; 9D:       F8               CLC
; 9E:       5D               POP RBP
; 9F:       C3               RET
; A0:       CC0A             BREAK 10                         ; error trap
; A2:       02               BYTE #X02
; A3:       19               BYTE #X19                        ; INVALID-ARG-COUNT-ERROR
; A4:       9A               BYTE #X9A                        ; RCX
; A5:       CC0A             BREAK 10                         ; error trap
; A7:       04               BYTE #X04
; A8:       08               BYTE #X08                        ; OBJECT-NOT-FIXNUM-ERROR
; A9:       FE1B01           BYTE #XFE, #X1B, #X01            ; RDX
NIL

Теперь, если мы добавим оптимизацию скорости, размер кода немного увеличится. (Однако это не обязательно плохо. Некоторые оптимизации скорости, такие как развертка цикла или функция вложения, будут генерировать больший код.)

CL-USER> 
(defun int-identity (x)
  (declare (type fixnum x)
           (optimize (speed 3)))
  x)

STYLE-WARNING: redefining COMMON-LISP-USER::INT-IDENTITY in DEFUN
INT-IDENTITY
CL-USER> (disassemble 'int-identity)
; disassembly for INT-IDENTITY
; Size: 20 bytes. Origin: #x1004A5D23D
; 3D:       488BE5           MOV RSP, RBP                     ; no-arg-parsing entry point
; 40:       F8               CLC
; 41:       5D               POP RBP
; 42:       C3               RET
; 43:       CC0A             BREAK 10                         ; error trap
; 45:       04               BYTE #X04
; 46:       19               BYTE #X19                        ; INVALID-ARG-COUNT-ERROR
; 47:       FE9A01           BYTE #XFE, #X9A, #X01            ; RBX
; 4A:       CC0A             BREAK 10                         ; error trap
; 4C:       04               BYTE #X04
; 4D:       08               BYTE #X08                        ; OBJECT-NOT-FIXNUM-ERROR
; 4E:       FE1B01           BYTE #XFE, #X1B, #X01            ; RDX
NIL

Наконец, когда мы удаляем безопасность, мы наконец получаем некоторый действительно короткий код, всего 9 байтов:

CL-USER> 
(defun int-identity (x)
  (declare (type fixnum x)
           (optimize (speed 3)
                     (safety 0)))
  x)

STYLE-WARNING: redefining COMMON-LISP-USER::INT-IDENTITY in DEFUN
INT-IDENTITY
CL-USER> (disassemble 'int-identity)
; disassembly for INT-IDENTITY
; Size: 9 bytes. Origin: #x1004AFF3E2
; 2:       488BD1           MOV RDX, RCX                      ; no-arg-parsing entry point
; 5:       488BE5           MOV RSP, RBP
; 8:       F8               CLC
; 9:       5D               POP RBP
; A:       C3               RET

Ответ 3

Используя предложение :lparallel от @coreump, я смог получить последовательные прогоны 0.125 секунд, заметно быстрее, чем gcc -O3 0.175. Различные методы, предложенные в комментариях для компиляции файла и вложения функции image-add, не привели к заметному ускорению. Вот самый быстрый код.

(load "~/quicklisp/setup.lisp")
(ql:quickload :lparallel)

(declaim (optimize (speed 3) (safety 0)))

(defparameter HORZ 800)
(defparameter VERT 800)

(defparameter PERF-REPS 1000)

(setf lparallel:*kernel* (lparallel:make-kernel 4))

(defun test ()
  (declare (type fixnum HORZ VERT PERF-REPS))
  (let ((to (make-array (* HORZ VERT) :element-type 'single-float))
        (fm (make-array (* HORZ VERT) :element-type 'single-float)))
    (time (dotimes (_ PERF-REPS)
            (lparallel:pmap-into to (lambda (x) (+ x 42f0)) fm)))))

(test)

EDIT: Я хочу заметить, что это не совсем справедливо: я добавил явный parallelism код Lisp, но не код C. Тем не менее, важно отметить, насколько это легко сделать с Lisp. Поскольку главным преимуществом Lisp over C в этом сценарии является краткость кода и относительная легкость добавления таких функций, как parallelism, компромисс находится в правильном направлении (для иллюстрации относительной гибкости Lisp). Я подозреваю, что параллельный код C (если и когда я смогу его реализовать) будет быстрее, чем код Lisp.