Выполнение вызова функции в Common Lisp SBCL - PullRequest
4 голосов
/ 17 июня 2019

Я новичок в Common Lisp и столкнулся с производительностью, которая просто показалась мне странной. Я проверяю, делится ли число на 10, используя rem в цикле. Если я перенесу проверку в функцию, она будет работать в 5 раз медленнее. Что вызвало бы это?

Я использую sbcl 1.4.5 на 64-битной Ubuntu 18.04.

(defun fn (x)
  (= 0 (rem x 10))
  )

(defun walk-loop-local (n)
  (loop for i from 1 to n do
       (= 0 (rem i 10))
       ))

(defun walk-loop-func (n)
  (loop for i from 1 to n do
       (fn i)
       ))

(time (walk-loop-local 232792560))
(time (walk-loop-func 232792560))

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

CL-USER> (load "loops.lisp")
Evaluation took:
  0.931 seconds of real time
  0.931389 seconds of total run time (0.931389 user, 0.000000 system)
  100.00% CPU
  2,414,050,454 processor cycles
  0 bytes consed

Evaluation took:
  4.949 seconds of real time
  4.948967 seconds of total run time (4.948967 user, 0.000000 system)
  100.00% CPU
  12,826,853,706 processor cycles
  0 bytes consed

Ответы [ 3 ]

3 голосов
/ 17 июня 2019

Common Lisp позволяет динамически переопределять функции: если вы переопределили fn в течение ок.Через 5 секунд вашего второго теста рабочий цикл переключится на вызов нового определения fn во время работы.Эта функция имеет некоторые ограничения на то, как компилировать вызовы функций и как их оптимизировать, когда это необходимо.

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

Вызов в файле именованной функции, котораяопределяется в том же файле относится к этой функции, если только эта функция не была объявлена ​​ notinline .Последствия не указываются, если функции переопределяются индивидуально во время выполнения или многократно определены в одном и том же файле.

Функция смешивает проверку ошибок и вычисления, которые вы хотите выполнить.На границах вызова функции у вас обычно есть пролог, в котором проверяются ваши входные данные, и эпилог, где результаты могут быть «упакованы»: если компилятор знает, что локально переменная всегда является одинарным числом, он может использовать необработанное представление чисел во времяэкстент функции, но при возврате результата это должен быть допустимый тип Lisp, что означает приведение его к теговому значению, например.

Компилятор SBCL пытается обеспечить безопасность кода,где safe означает никогда не вызывать код с неопределенным поведением в спецификации Lisp.Однако обратите внимание, что если вы вызываете fn со строковым вводом, ожидается, что код обнаружит ошибку типа.В отличие от C, ошибка типа во время выполнения в Лиспе четко определена (при условии, что тип , объявленный , который по умолчанию равен T, охватывает все возможные значения во время выполнения).Итак, компиляция кода на Лиспе для безопасности имеет тенденцию добавлять много проверок на ошибки в нескольких точках программы.

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

Оптимизация в SBCL происходит потому, что существует статический анализ, который отображает переменные на элементы решетки типов Lisp (and и or - это в основном наибольшая нижняя граница и нижняя верхняя граница для типов с типами T и типом nil на обоих концах).SBCL сообщает только об ошибках, которые обязательно произойдут: у вас есть ошибка, если вы вызываете функцию, которая принимает целые числа от 0 до 5, если вы вызываете ее с входом, который, как известно, всегда выше 5 или ниже нуля (оба набора не имеют пересечения), но у вас нет предупреждения, если вы вызываете его с целым числом от 2 до 10. Это безопасно, поскольку компилятор может отложить проверку ошибок во время выполнения, в отличие от других языков, где среда выполнения не имеет смысла типов (пытаясь предупреждать каждый раз, когдакод может иметь ошибки, что приведет к большому количеству предупреждений, учитывая открытость Lisp).

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

Обратите внимание, что накладные расходы на проверку ошибок являются проблемой, если они выполняются много времени (относительно остальной части кода).Если вы заполняете большой массив одинарными числами и применяете к нему числовой код, имеет смысл использовать специализированный тип массива, такой как (simple-array single-float), или объявить локальные переменные как плавающие с (declare (type single-float x)), так что вы не 't проверьте, что каждое значение фактически является float.В других случаях накладные расходы недостаточно высоки, чтобы тратить слишком много времени на их уменьшение.

3 голосов
/ 18 июня 2019

Вы используете компилятор SBCL:

(defun walk-loop-local (n)
  (loop for i from 1 to n do
       (= 0 (rem i 10))))

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

Таким образом, нет накладных расходов, поскольку нет кода.

Используйте (disassemble #'walk-local-form) для проверки кода компиляции.

Если я перенесу проверку в функцию, она будет работать в 5 раз медленнее. Что вызвало бы это?

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

Фактическое измерение накладных расходов на вызовы

(defparameter *i* nil)

(defun walk-loop-local (n)
  (loop for i from 1 to n do
        (setf *i* (= 0 (rem i 10)))))

(defun fn (x)
  (setf *i* (= 0 (rem x 10))))

(defun walk-loop-func (n)
  (loop for i from 1 to n do
       (fn i)))

В приведенном выше случае код не удаляется.

CL-USER> (time (walk-loop-local 232792560))
Evaluation took:
  5.420 seconds of real time
  5.412637 seconds of total run time (5.399134 user, 0.013503 system)
  99.87% CPU
  6,505,078,020 processor cycles
  0 bytes consed

NIL
CL-USER> (time (walk-loop-func 232792560))
Evaluation took:
  6.235 seconds of real time
  6.228447 seconds of total run time (6.215409 user, 0.013038 system)
  99.89% CPU
  7,481,974,847 processor cycles
  0 bytes consed

Как видите, накладные расходы при вызове функции невелики.

2 голосов
/ 17 июня 2019

Каждый вызов функции добавляет накладные расходы.Это то, что вы измеряете.
Вы можете объявить функцию fn встроенной, а также попробовать изменить флаги компилятора для оптимизации выполнения во время выполнения (в отличие от отладочной информации или безопасности).Сейчас я разговариваю по телефону, но при необходимости могу добавить ссылку на гиперспец.

BR, Эрик

...