Чому "пускають" швидше за допомогою лексичного обсягу?


31

Читаючи вихідний код для dolistмакросу, я наткнувся на наступний коментар.

;; Це не є надійним тестом, але це не має значення, тому що обидві семантики є прийнятними, одне трохи швидше при динамічному оцінюванні, а інше трохи швидше (і має більш чітку семантику) з лексичним визначенням .

Який посилався на цей фрагмент (який я спростив для наочності).

(if lexical-binding
    (let ((temp list))
      (while temp
        (let ((it (car temp)))
          ;; Body goes here
          (setq temp (cdr temp)))))
  (let ((temp list)
        it)
    (while temp
      (setq it (car temp))
      ;; Body goes here
      (setq temp (cdr temp)))))

Мене здивувало, побачивши letформу, яка використовується всередині циклу. Я думав, що це повільно порівняно з неодноразовим використанням setqоднієї і тієї ж зовнішньої змінної (як це робиться у другому випадку вище).

Я б відкинув це як ніщо, якби не коментар безпосередньо над ним, чітко кажучи, що це швидше, ніж альтернатива (з лексичною прив'язкою). Отже ... Чому це?

  1. Чому наведений вище код відрізняється ефективністю на лексичному та динамічному зв’язуванні?
  2. Чому letформа швидша з лексичною?

Відповіді:


38

Лексичне зв’язування проти динамічного зв’язування загалом

Розглянемо наступний приклад:

(let ((lexical-binding nil))
  (disassemble
   (byte-compile (lambda ()
                   (let ((foo 10))
                     (message foo))))))

Він збирає та негайно розбирає просту lambdaз локальною змінною. Якщо lexical-bindingвимкнено, як і вище, код байти виглядає так:

0       constant  10
1       varbind   foo
2       constant  message
3       varref    foo
4       call      1
5       unbind    1
6       return    

Зверніть увагу varbindта varrefінструкції. Ці вказівки пов'язують і шукають змінні відповідно змін за своїм ім'ям у глобальному середовищі прив'язки на купі пам'яті . Все це негативно позначається на продуктивності: воно включає хешування рядків та порівняння , синхронізацію для глобального доступу до даних та повторний доступ до пам’яті в купі, що погано грає з кешуванням процесора. Крім того, динамічні прив'язки змінної повинні бути відновлені до їх попередньої змінної в кінці let, що додає nдодаткові пошуки для кожного letблоку з nприв’язками.

Якщо ви посилаєтесь lexical-bindingна tнаведений вище приклад, байт-код виглядає дещо інакше:

0       constant  10
1       constant  message
2       stack-ref 1
3       call      1
4       return    

Зауважте, що varbindі varrefповністю пішли. Локальна змінна просто висувається на стек і посилається на постійне зміщення через stack-refінструкцію. По суті, змінна пов'язана і читається з постійним часом , в пам'яті стік читає і записує, що є повністю локальним і, таким чином, добре поєднується з одночасним кешуванням і кешуванням процесора , і зовсім не включає жодних рядків.

Як правило, з лексичними зв'язують пошуків локальних змінних (наприклад let, setqі т.д.) мають набагато менше часу виконання і пам'яті складності .

Цей конкретний приклад

Завдяки динамічній прив'язці кожен з них несе певну ефективність за вищезазначеними причинами. Чим більше літ, тим більш динамічні прив'язки змінних.

Зокрема, з додатковою частиною letв loopтілі пов'язану змінну потрібно буде відновити при кожній ітерації циклу , додаючи додатковий пошук змінної до кожної ітерації . Отже, швидше зберегти випущений з корпусу циклу, щоб змінна ітерація була скинута лише один раз , після того, як закінчиться весь цикл. Однак це не особливо елегантно, оскільки ітераційна змінна є обмеженою, перш ніж вона дійсно потрібна.

З лексичним зв’язуванням, lets дешеві. Зокрема, letвсередині циклу циклу не гірше (продуктивне), ніж letзовнішнє тіло циклу. Отже, ідеально добре пов'язувати змінні якомога локальніше і тримати змінну ітерації обмеженим тілом циклу.

Це також трохи швидше, тому що він збирає набагато менше інструкцій. Розгляньте наступні розбірки (місцевий пуск з правого боку):

0       varref    list            0       varref    list         
1       constant  nil             1:1     dup                    
2       varbind   it              2       goto-if-nil-else-pop 2 
3       dup                       5       dup                    
4       varbind   temp            6       car                    
5       goto-if-nil-else-pop 2    7       stack-ref 1            
8:1     varref    temp            8       cdr                    
9       car                       9       discardN-preserve-tos 2
10      varset    it              11      goto      1            
11      varref    temp            14:2    return                 
12      cdr       
13      dup       
14      varset    temp
15      goto-if-not-nil 1
18      constant  nil
19:2    unbind    2
20      return    

У мене немає поняття, однак, що викликає різницю.


7

Якщо коротко - динамічне зв'язування відбувається дуже повільно. Лексичне зв’язування надзвичайно швидко під час виконання. Принципова причина полягає в тому, що лексичне зв’язування може бути вирішене під час компіляції, тоді як динамічне зв’язування не може.

Розглянемо наступний код:

(let ((x 42))
    (foo)
    (message "%d" x))

Під час його компіляції letкомпілятор не може знати, чи fooотримає (динамічно зв'язана) змінна x, тому він повинен створити прив'язку для xі повинен зберегти ім'я змінної. З лексичним зв'язуванням, компілятор просто скидає значення з xна палітурки стека, без його імені, і отримує доступ до правої записи безпосередньо.

Але зачекай - ще є. За допомогою лексичного зв’язування компілятор може переконатися, що саме ця прив'язка використовується xлише в коді до message; оскільки xніколи не модифікується, це безпечно вводити в рядок xі отримувати урожай

(progn
  (foo)
  (message "%d" 42))

Я не думаю, що поточний компілятор байт-кодів виконує цю оптимізацію, але я впевнений, що це зробить це в майбутньому.

Отже, коротше:

  • динамічне прив'язування - це великовагова операція, яка дозволяє зробити кілька можливостей для оптимізації;
  • лексичне зв’язування - це легка операція;
  • Лексичне зв’язування значення лише для читання часто можна оптимізувати.

3

Цей коментар не говорить про те, що лексичне зв’язування є або швидшим, або повільнішим, ніж динамічне зв'язування. Скоріше, це дозволяє припустити, що ці різні форми мають різні експлуатаційні характеристики під лексичним та динамічним зв'язуванням, наприклад, одна з них є кращою в одній дисципліні обов'язкової, а інша - краща в іншій.

Так це лексична сфера швидше , ніж динамічний обсяг? Я підозрюю, що в цьому випадку немає великої різниці, але я не знаю - вам дійсно доведеться її вимірювати.


1
Немає varbindкоду, складеного під лексичним прив'язкою. У цьому вся суть і мета.
місячник

Хм. Я створив файл, що містить вищезгадане джерело, починаючи з ;; -*- lexical-binding: t -*-, завантажував його і називав (byte-compile 'sum1), припускаючи, що він створив визначення, складене під лексичне прив'язування. Однак, схоже, цього немає.
gsg

Видалено коментарі до байтового коду, оскільки вони грунтувалися на тому неправильному припущенні.
gsg

відповідь показує lunaryon, що цей код явно знаходиться швидше під лексичним зв'язуванням (хоча, звичайно , тільки на мікрорівні).
шості

@gsg Ця декларація є лише стандартною змінною файлу, яка не впливає на функції, викликані поза відповідного файлового буфера. IOW, це має ефект лише в тому випадку, якщо ви відвідаєте вихідний файл, а потім викликаєте byte-compileпоточний відповідний буфер, який - до речі, саме те, що робить компілятор байтів. Якщо ви посилаєтесь byte-compileокремо, вам потрібно чітко встановити lexical-binding, як я зробив у своїй відповіді.
місячник
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.