Эффективное вычисление нескольких результатов - PullRequest
0 голосов
/ 28 мая 2018

Context

В настоящее время я оптимизирую библиотеку для научных вычислений.Я довольно новичок в Commom Lisp.Функции, которые я использую, довольно малы и выполняются в течение ок.От 10 нс до нескольких сотен наносекунд на среднем ноутбуке.Производительность уже довольно близка к C, но я хочу получить каждый бит speed .

Я использую SBCL и его примечания компилятора и макрос (time) дляоптимизировать (любой общий совет приветствуется).В настоящее время я оптимизирую однопотоковую строку вычислений, которая будет включена в независимые потоки в будущем.

Задача

Ради аргумента, скажем, у меня есть функция (foo), который добавляет два списка из 3 переменных термин за термином.После оптимизации это может быть что-то вроде:

(defun foo (a1 a2 a3 b1 b2 b3)
  (declare (optimize (speed 3))
           (type double-float a1 a2 a3 b1 b2 b3))
  (list (+ a1 b1) (+ a2 b2) (+ a3 b3)))

И я рассчитываю время:

(time (dotimes (it 1000000 t) (foo 1.0d0 2.0d0 2.351d0 223.0d0 124.2d0 321d0)))

SBCL notes

Из того, что япоймите, компилятор жалуется, что «приведение» результата в список стоит дорого.

note: doing float to pointer coercion (cost 13)

Что бы я хотел

Жалобы SBCL кажутся разумными, поэтому я ищуспособ покончить с этими надоедливыми списками, которые мне все равно придется отогнать снова в какой-то момент позже, чтобы передать их другим вычислениям.Я готов пойти на низком уровне для этого и потерять (некоторые) абстракции.Вариант использования может выглядеть следующим образом:

(let ((res1 0d0)
      (res2 0d0)
      (res3 0d0))
 (declare (type double-float res1 res2 res3))
 (magic-abstraction-I-want res1 res2 res3 (foo 1d0 1d0 1d0 1d0 1d0 1d0)))

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

То, что я пробовал / Подумайте о попытке

Inline

Я вижу огромные улучшения производительности простых функций, таких как foo при установке:

(declaim (inline foo))

ОтНасколько я понимаю, она как бы «расширяет» функцию и записывает ее на уровне, который она вызывает.Это правильно, что он делает именно?Действительно ли это делает то, что я хочу?Решает ли это проблему «приведения к спискам» каким-либо образом?

(Кроме того, не стесняйтесь давать общие рекомендации по оптимизации скорости, если вы видите из того, что я пишу, что-то, что я мог неправильно понять)

Редактировать: Узнав о values

Я изменил foo, теперь он:

(defun foo (a1 a2 a3 b1 b2 b3)
  (declare (optimize (speed 3))
           (type double-float a1 a2 a3 b1 b2 b3))
  (values (+ a1 b1) (+ a2 b2) (+ a3 b3)))

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

(time (dotimes (it 1000000 t) (foo 1.0d0 2.0d0 2.351d0 223.0d0 124.2d0 321d0)))

Однако вызовы с inline намного быстрее и ничего не отнимают (как я предполагаю):

(declaim (inline foo))
;;;;
(let ((r1 0d0) (r2 0d0) (r3 0d0)
      (a1 1d0) (a2 2d0) (a3 3d0)
      (b1 4d0) (b2 5d0) (b3 6d0))
  (declare (optimize (speed 3))
           (type double-float r1 r2 r3 a1 a2 a3 b1 b2 b3))
  (time (dotimes (it 1000000 t) (setf (values r1 r2 r3) (foo a1 a2 a3 b1 b2 b3)))))

Производительность точно такая же как:

(let ((r1 0d0) (r2 0d0) (r3 0d0)
      (a1 1d0) (a2 2d0) (a3 3d0)
      (b1 4d0) (b2 5d0) (b3 6d0))
  (declare (optimize (speed 3))
           (type double-float r1 r2 r3 a1 a2 a3 b1 b2 b3))
  (time (dotimes (it 1000000 t)
          (setf r1 (+ a1 b1))
          (setf r2 (+ a2 b2))
          (setf r3 (+ a3 b3)))))

Это именно то, что я хотел.Последнее очень незначительное - SBCL все еще жалуется на оптимизацию foo, но я могу просто приглушить ее.

1 Ответ

0 голосов
/ 29 мая 2018

Хорошо, поэтому первое, что я хочу сделать, это объяснить, что означает «принуждение к указателю».

(Предварительные условия: понимание того, что такое память в машине, биты и приблизительное представление о Cмодель памяти).

В Лиспе, который динамически типизирован, значения имеют типы, а не переменные.Следовательно, вам нужно некоторое согласованное представление памяти, которое можно использовать для передачи любого значения любого типа, и вы должны быть в состоянии определить тип для этого.(Обратите внимание, что в некоторых строго типизированных функциональных языках все еще может потребоваться некоторое представление структурированной памяти для сборки мусора, чтобы следовать указателям, или единый способ представления всех указателей в слове, чтобы полиморфизм работал).Типичным выбором для этого является указатель, который всегда является словом (скажем, 8 байтов), и тогда объект, на который указывает указатель, может иметь заголовок с дополнительной информацией о его типе.В C это будет выглядеть примерно так:

struct LispObj_s {
  uint32_t length;
  uint16_t gc_data;
  uint16_t type_code;
  union {
    int64_t as_smallint;
    float as_float
    double as_double;
    char as_string;
    ... /* bignums, arrays, cons, structs, etc */
  }
}

typedef LispObj_s * LispObj

Это плохо, потому что тогда многие часто используемые вещи (а именно целые числа) имеют огромные накладные расходы: одно слово для указателя на объект, другое для заголовка (говоря «я целое число, и я 1 слово в длину»), и один для самого числа.Это накладные расходы на 200%, и это означает, что целые числа требуют разыменования указателя и возможного промаха кэша.Итак, есть оптимизация: вы используете несколько наименее значимых битов указателей, чтобы сказать, что это за тип (они бесплатны, так как все выровнено по словам, поэтому три младших значащих бита всегда равны 0).Тогда вы можете иметь, что если (скажем) последний бит равен 0, то у вас есть фиксированный номер (небольшое число, где арифметика быстрая, в данном случае 63 бита), если ваши последние биты равны 101, у вас есть указатель на консоль,если они равны 111, то указатель на двойное число, 011 на число с плавающей запятой и 001 указатель на что-то еще.Теперь целые числа, умножения и числа с плавающей точкой дешевле, и это хорошо, но недостатком является то, что теперь вам нужно быть немного осторожнее, чтобы убедиться, что теги всегда правильные.Проблема в том, что мы не можем сделать то же самое для двойников, что мы сделали для целых чисел.На самом деле невозможно просто отрубить 2 бита от двойного, как вы делаете с целыми числами, особенно если вы хотите совместимости с внешними программами.

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

Когда вы хотите вернуть или передать (например) число с плавающей точкой в ​​качестве аргумента, код, получающий его, не обязательно знает, какого рода объектон получит, и код, отправляющий его, конечно, не будет знать, что получатель хочет получить определенный тип данных.Таким образом, вам нужно преобразовать его в некую унифицированную форму, в которой также указано, к какому типу он относится, и для этого вам нужно выделить для него некоторую память и передать тегированный указатель на эту память.При вызове функции вы не знаете, что вызываемая функция будет делать с плавающей точкой, и поэтому вы не можете (см. Позже) разместить ее в стеке, потому что вызываемый объект может, например, поместить ее в глобальную переменную, а затемчуть позже эта переменная будет указывать на мусорные данные или не отображенную память.Еще хуже, когда вы возвращаетесь, поскольку вы будете размещать float в своем собственном кадре стека, который вы собираетесь уничтожить.Распределение имеет тенденцию быть медленным (хотя и намного быстрее, чем, например, в C; главным образом потому, что вы пишете в память, а не в кэш), а чтение выделенных объектов имеет тенденцию быть медленным (потому что вам нужно проверять и удалять теги и разыменовывать указатель, и частоиз-за отсутствия кэша) именно поэтому sbcl жалуется, когда оптимизатору приходится выделять и упаковывать объект.

Одна вещь, которую можно сделать, чтобы всеocation быстрее объявить динамический экстент.Это говорит Лиспу, что вы обещаете, что какой-то объект не будет указывать куда-либо после окончания текущей динамической области и, следовательно, он может быть размещен в стеке.Распределение стека происходит быстрее, а локальный кэш больше, а освобождение стека в основном бесплатное, поскольку gc не задействован.Вы можете сделать это при передаче аргументов, но не при возврате.

Использование values немного похоже на размещение возвращаемого списка в стеке (параметр & rest для функции также может быть выделен в стеке, если динамический экстент в sbcl)за исключением того, что это возможно, и поэтому это более эффективный способ вернуть несколько значений.К сожалению, из-за единообразного представления памяти невозможно получить преимущество от распределения чисел.


Ответ

Однако, если мы знаем о функции, которую мы вызываем, мы можем сложить-выделите пространство для его возвращаемых значений заранее и затем поместите функцию туда.Если мы сделаем это, мы также сможем избежать приведения типа с плавающей точкой к указателю следующим образом: sbcl имеет оптимизированное представление для массивов с плавающей точкой, где вместо массива указателей на плавающие числа плавающие элементы хранятся напрямую (например, как float*, а не float**):

(defun foo3 (a1 a2 a3 b1 b2 b3 result)
  (declare (optimize (speed 3) (safety 0))
           (type double-float a1 a2 a3 b1 b2 b3)
           (type (simple-array double-float (3)) result))
  (setf (aref result 0) (+ a1 b1)
        (aref result 1) (+ a2 b2)
        (aref result 2) (+ a3 b3))
  nil)

И тогда мы можем назвать это следующим запутанным способом:

(let ((foo-result (make-array '(3) :element-type 'double-float :initial-element 0d0)))
  (declare (dynamic-extent foo-result))
  (foo a1 a2 a3 b1 b2 b3 foo-result)
  (let ((c1 (aref foo-result 0)) ...)
    ...))

Таким образом, мы выделяем пространство для результатов в стеке, затем foo3заполняет их, затем мы извлекаем их из стека, предположительно в регистры.Это должно быть почти так же быстро, как foo3, возвращающее свой результат в регистрах, и, что особенно важно, не требует выделения кучи ((Edit: я не думаю, что это так), если sbcl предполагает, что функция не изменится, она может вызватьпроверка типа / распаковка прямо в ядро ​​функции).

К сожалению, синтаксис неприятен, но в Lisp: есть макросы.Можно реализовать специальные макросы для определения функции, такие как foo3, и специальный макрос для вызова и привязки результатов, но это плохо, потому что вы теперь разбили мир на два типа функций и не можете использовать функции более высокого порядка дляfoo, и вы, возможно, делаете макросы-генерации макросов, которые сложно отладить.Вместо этого идея заключается в следующем: мы генерируем быструю версию с нашим странным соглашением о вызовах и функцией-оберткой, которая вызывает ее и устанавливается в строку.Затем всякий раз, когда мы вызываем функцию-обертку, мы получаем преимущество быстрого вызова, и компилятор снимает с нее все расходы на обертку.

(defmacro fast-defun (name num-values lambda-list &body body)
  (assert (and (integerp num-values) (plusp num-values)))
  (let ((fast-name (gensym (symbol-name name)))
        (result (gensym "RESULT"))
        (vars (loop repeat num-values collect (gensym "V")))
    `(progn
        (defun ,fastname ,(cons result lambda-list) ;;FIXME: this only works if lambda-list is a list of symbols
          (declare (optimize (speed 3) (safety 0))
                    (type (simple-array double-float (,num-values))))
          ;; Todo: move declarations here from body
          (multiple-value-bind ,vars (progn ,@body)
            ,@(loop for v in vars
                    for n from 0
                    collect `(setf (svref ,result ,n) ,v))
            nil))
       (declaim (inline ,name))
       (defun ,name ,lambda-list
         (let ((,result (make-array '(,num-values) :element-type 'double-float :initial-element 0d0)))
           (declare (dynamic-extent ,result) (optimize (speed 3)))
           (,fast-name ,result ,@lambda-list)
           (values ,@(loop for n below num-values collect `(aref ,result n))))))))

Этот макрос не в самом общем виде, но вы можете использовать его так же, как и (defun foo выше, но (fast-defun foo 3.


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

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

...