Как одна буква в ассемблере стоит 3× производительности

от автора

Я хочу показать вам, как одна буква в ассемблере может стоить 3× производительности. Не в теории — на живых замерах. По дороге мы заглянем внутрь процессора: Register Alias Table, partial register merge, scheduler, latency vs throughput, и даже обнаружим, что делитель выдаёт остаток раньше частного.

Но начнём с основ. Приготовьтесь: кроличья нора окажется глубже, чем кажется.

Немного контекста

Процессор x86-64 работает с регистрами — быстрыми ячейками прямо внутри CPU. Их немного (16 основных), зато доступ к ним — за доли такта, в отличие от оперативной памяти, где задержка может достигать сотен тактов.

Главный нюанс: у каждого регистра есть части разного размера, унаследованные от предыдущих поколений архитектуры:

┌───────────────────────────────────────────────────────────────┐│                           rax (64 бит)                        │├───────────────────────────────┬───────────────────────────────┤│          (верхние 32)         │           eax (32 бит)        ││                               ├───────────────┬───────────────┤│                               │               │   ax (16 бит) ││                               │               ├───────┬───────┤│                               │               │ah (8) │al (8) │└───────────────────────────────┴───────────────┴───────┴───────┘

rax, eax, ax — это не разные регистры. Это один и тот же регистр, просто вы указываете, какую часть хотите использовать. Запись в eax меняет нижние 32 бита rax. Запись в ax — нижние 16. Это важно — и именно здесь спрятана ловушка, о которой пойдёт речь.

Таких регистров 16: rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp, r8r15. Для нашей истории важны два: rax и rdx — потому что именно их использует инструкция деления.

Целочисленное деление на x86

Инструкция div (беззнаковое деление) и idiv (знаковое) работают так: делимое берётся из пары регистров, делитель — из операнда, результат раскладывается обратно:

┌──────────┬────────────────┬──────────────┬─────────────────────┐│ Размер   │ Делимое        │ Частное в    │ Остаток в           │├──────────┼────────────────┼──────────────┼─────────────────────┤│ 16-бит   │ dx:ax          │ ax           │ dx                  │├──────────┼────────────────┼──────────────┼─────────────────────┤│ 32-бит   │ edx:eax        │ eax          │ edx                 │├──────────┼────────────────┼──────────────┼─────────────────────┤│ 64-бит   │ rdx:rax        │ rax          │ rdx                 │└──────────┴────────────────┴──────────────┴─────────────────────┘

Перед делением нужно подготовить делимое в паре rdx:rax (или edx:eax, или dx:ax). Верхнюю часть обычно обнуляют через mov или xor.

Как работает цикл

В ассемблере нет for и while. Цикл — это метка, тело и условный переход:

    mov ecx, 100       ; счётчик = 100.loop:    ; ... тело цикла ...    dec ecx             ; счётчик -= 1    jnz .loop           ; если не ноль — прыгаем назад

dec ecx уменьшает счётчик на 1 и выставляет флаги. jnz (jump if not zero) проверяет флаг и прыгает, если результат не ноль. Процессор умеет сливать эти две инструкции в одну операцию (macro-fusion), поэтому пара dec+jnz стоит всего 1 такт.

Эксперимент

Вопрос: сколько тактов процессора стоит одна инструкция div?

Контекст может сильно влиять на результат — кэш, предсказание переходов, другие инструкции рядом. Чтобы измерить стоимость деления в чистом виде, напишем плоский цикл на 1920×1080 = 2 073 600 итераций. Почему такое число? Представьте рендер: 1920×1080 пикселей, на каждый — деление.

Тело цикла: два mov для подготовки делимого + инструкция деления. Все делят одно и то же: 536700 / 2700. Подготовка регистров — стандартная для каждого размера:

; 16-бит unsigned                ; 16-бит signedmov dx, 0x0008                   mov dx, 0x0008mov ax, 0x2B7C                   mov ax, 0x2B7Cdiv word [best_den]              idiv word [best_den]; 32-бит unsigned                ; 32-бит signedmov edx, 0                       mov edx, 0mov eax, 536700                  mov eax, 536700div dword [best_den]             idiv dword [best_den]; 64-бит unsigned                ; 64-бит signedmov rdx, 0                       mov rdx, 0mov rax, 536700                  mov rax, 536700div qword [best_den]             idiv qword [best_den]

Но сначала проверим, сколько стоит сам цикл без деления — только setup:

┌────────────────────────────────┬──────────┐│ Setup (без div)                │ cpr      │├────────────────────────────────┼──────────┤│ 16-бит (mov dx + mov ax)       │ 1924     │├────────────────────────────────┼──────────┤│ 32-бит (mov edx + mov eax)     │ 1928     │├────────────────────────────────┼──────────┤│ 64-бит (mov rdx + mov rax)     │ 1924     │└────────────────────────────────┴──────────┘  (cpr = cycles per row = total / 1080)

Все setup-ы дают ~1920 cpr — 1 такт на итерацию. Мувы бесплатны: они уходят на свободные ALU-порты параллельно с dec/jnz. Значит, всё что мы замерим дальше — это чистая стоимость деления.


Глава 1. Странная таблица

Результаты:

┌────────┬───────────────┬────────────────┐│        │ signed (idiv) │ unsigned (div) │├────────┼───────────────┼────────────────┤│ 16-bit │ 44 447        │ 44 457         │├────────┼───────────────┼────────────────┤│ 32-bit │ 13 445        │ 13 445         │├────────┼───────────────┼────────────────┤│ 64-bit │ 52 289        │ 48 160         │└────────┴───────────────┴────────────────┘  (cycles per row, 1920 итераций на строку)

Переведём в такты на одну итерацию (cpr / 1920) и вычтем стоимость цикла без инструкции деления (1 такт):

┌────────┬──────────────┬──────────────┬──────────────┬──────────────┐│        │ idiv (полн.) │ idiv (чист.) │ div (полн.)  │ div (чист.)  │├────────┼──────────────┼──────────────┼──────────────┼──────────────┤│ 16-bit │ 23.2         │ 22.2         │ 23.2         │ 22.2         │├────────┼──────────────┼──────────────┼──────────────┼──────────────┤│ 32-bit │  7.0         │  6.0         │  7.0         │  6.0         │├────────┼──────────────┼──────────────┼──────────────┼──────────────┤│ 64-bit │ 27.2         │ 26.2         │ 25.1         │ 24.1         │└────────┴──────────────┴──────────────┴──────────────┴──────────────┘  (тактов на итерацию; чист. = минус стоимость цикла без деления)

16-бит — ~22 такта на деление. 64-бит — ~24–26 тактов. Примерно один порядок.

А 32-бит — ~6 тактов. Почти в 3 раза быстрее.

Если бы время росло с увеличением битности — 16 < 32 < 64 — это было бы логично: больше бит, сложнее операция. Но здесь именно 32-бит выбиваются вниз. Код одинаковый по структуре: два mov, один div. Числа одни и те же. Единственная разница — размер регистров. Почему 32-бит настолько быстрее?


Глава 2. Что происходит внутри процессора

Чтобы понять, откуда разница, нужно заглянуть под капот.

Регистр rax, который вы видите в коде — это не физический регистр. Это имя. Псевдоним.

Внутри процессора есть register file — массив из ~180 безымянных физических регистров. И таблица переназначений — Register Alias Table (RAT) — которая в каждый момент говорит: «rax — это сейчас физический регистр №47».

Когда вы пишете mov rax, 10, процессор берёт из пула новый физический регистр — скажем, №112 — записывает туда 10 и обновляет RAT: rax → №112. Старый №47 живёт, пока все инструкции, которые его читают, не завершатся. Это основа out-of-order execution — параллельного исполнения инструкций.

Теперь ключевой момент. В x86-64 есть правило:

  • Запись в 32-битный регистр (mov edx, ...) обнуляет верхние 32 бита. Полная перезапись. Процессор создаёт новый физический регистр, старая версия не нужна. Зависимости нет.

  • Запись в 16-битный регистр (mov dx, ...) верхние 48 бит не трогает. Процессор должен создать новый rdx, где нижние 16 бит — новые, а верхние 48 — из предыдущей версии. Для этого нужно дождаться готовности предыдущего rdx. Это создаёт зависимость, которая нам не нужна — верхние 48 бит мы не используем, и они пролетают над гнездом кукушки каждый раз в неизменном виде.

В нашем 16-битном цикле это катастрофа. div word записывает результат в ax и dx — тоже 16-битные записи. А div word при этом тоже должен склеить свой результат с верхними 48 битами предыдущего rax и rdx — это ещё один merge. Получается, верхние 48 бит никому не нужны — ни div, ни mov dx — но они протаскиваются через каждую итерацию, создавая цепочку зависимостей: div → merge → mov dx → merge → div → .... Каждая итерация ждёт предыдущую.

В 32-битном варианте mov edx, 0 обнуляет верхние 32 бита — цепочка разрывается. Итерации становятся независимыми, и процессор может перекрывать их.

Здесь нужно ввести два понятия:

Latency — сколько тактов от начала div до готовности результата. Для 16-битного div на Coffee Lake: 21–22 такта (данные uops.info).

Throughput — через сколько тактов делитель может принять следующую операцию. Для 16-битного div: 6 тактов (данные uops.info).

Делитель частично конвейеризирован: через ~6 тактов вход свободен, хотя предыдущее деление ещё не закончило.

16-бит с mov dx (ненужная зависимость): каждая итерация ждёт предыдущую → bottleneck = latency = ~22 такта.

32-бит с mov edx (нет зависимости): итерации перекрываются → bottleneck = throughput = ~6 тактов.

22 / 6 = ~3.7×. Вот почему 32-бит так выбивается в нашей таблице.

Если теория верна, то и 16-битный div word должен ускориться, если мы уберём ненужную зависимость — заменим mov dx на mov edx. Проверим.


Глава 3. Проверяем теорию

Заменяем 16-битные mov на 32-битные в обоих вариантах:

; div word — было:               ; div word — стало:mov dx, 0x0008                   mov edx, 8mov ax, 0x2B7C                   mov eax, 0x2B7Cdiv word [best_den]              div word [best_den]; idiv word — было:              ; idiv word — стало:mov dx, 0x0008                   mov edx, 8mov ax, 0x2B7C                   mov eax, 0x2B7Cidiv word [best_den]             idiv word [best_den]

Одна буква e. Тот же div word, те же числа. Сначала проверим, что сам setup по-прежнему бесплатен:

┌──────────────────────────────────┬──────┐│ Setup (без div)                  │ cpr  │├──────────────────────────────────┼──────┤│ 32-бит для word (mov edx/eax)    │ 1925 │└──────────────────────────────────┴──────┘

1 такт — как и все остальные setup-ы. Теперь запускаем с делением:

┌───────────────────────┬───────────────┬────────────────┐│                       │ signed (idiv) │ unsigned (div) │├───────────────────────┼───────────────┼────────────────┤│ 16-bit (mov dx)       │ 44 447        │ 44 457         │├───────────────────────┼───────────────┼────────────────┤│ 16-bit (mov edx)      │ 15 368        │ 15 368         │├───────────────────────┼───────────────┼────────────────┤│ 32-bit                │ 13 445        │ 13 445         │├───────────────────────┼───────────────┼────────────────┤│ 64-bit                │ 52 289        │ 48 160         │└───────────────────────┴───────────────┴────────────────┘

В тактах (чистая стоимость деления):

┌───────────────────────┬──────────────┬──────────────┐│                       │ idiv (чист.) │ div (чист.)  │├───────────────────────┼──────────────┼──────────────┤│ 16-bit (mov dx)       │ 22.2         │ 22.2         │├───────────────────────┼──────────────┼──────────────┤│ 16-bit (mov edx)      │  7.0         │  7.0         │├───────────────────────┼──────────────┼──────────────┤│ 32-bit                │  6.0         │  6.0         │├───────────────────────┼──────────────┼──────────────┤│ 64-bit                │ 26.2         │ 24.1         │└───────────────────────┴──────────────┴──────────────┘

16-бит с mov edx15 368 cpr (7.0 тактов чистых). С mov dx было 44 447 cpr (22.2 такта). Втрое быстрее. Теория подтверждена.

Мы уже знаем из наших замеров, что сами mov-ы бесплатны — все setup-ы стоят 1 такт. Значит, вся разница — в зависимостях, которые 16-битные записи создают внутри цикла с div.


Глава 4. А что с 64-битным делением?

С 16-бит разобрались. Но в таблице осталась странность: 64-бит div qword — ~48 160 cpr, это ~25 тактов на итерацию. При этом зависимостей нет: mov rdx, 0 и mov rax, 536700 — полные 64-битные перезаписи, цепочка разорвана.

Значит, ~25 тактов — это не latency, а throughput 64-битного делителя. Он просто не может быстрее. Данные uops.info подтверждают: throughput div r64 на Coffee Lake = ~21 такт. На 64-битном делении CPU тратит 33 µops (32 из микрокода!) вместо 10 для 16/32-бит. Совершенно другая реализация.

Но если ~21 такт — это throughput, то latency должна быть больше. uops.info говорит: latency от 32 тактов. Значит, если создать зависимость между итерациями, время должно вырасти.


Глава 5. Где зависимость?

Создадим зависимость. После div qword (536700 / 2700): rax = 198, rdx = 2100. Мы знаем результат — значит, через вычитание можно вернуть rdx к нулю, сохранив зависимость:

mov rax, 536700sub rdx, 2100          ; 2100 - 2100 = 0, зависит от rdxdiv qword [best_den]

Запускаем: ~46 300 cpr. Столько же, сколько без зависимости. Время не выросло.

Странно. Зависимость div → sub → div точно есть. sub читает rdx, который записал div. Почему это не замедляет?


Глава 6. Ищу latency

Может, ~21 такт — это и есть latency, а не throughput? Может, для маленьких чисел делитель просто быстрее, чем обещает uops.info?

Попробуем увеличить число: 536700000, потом 5367000000000. Результат — тот же: ~46 000–48 000 cpr. Размер числа не влияет.


Глава 7. Две зависимости

div qword читает два входных регистра: rdx:rax. До сих пор мы создавали зависимость только по rdx. А что, если нужна зависимость по обоим?

После div: rax = 198, rdx = 2100. Через сложение возвращаем оба к исходным значениям:

add rax, 536502       ; 198 + 536502 = 536700, зависит от raxadd rdx, -2100        ; 2100 - 2100 = 0, зависит от rdxdiv qword [best_den]

Те же числа. Тот же результат. Запускаем: ~67 050 cpr = 34 такта на итерацию!

Было 24 такта без зависимости. Стало 34 с зависимостью. Разница 10 тактов. Вот настоящая latency. И ~34 — это как раз в диапазоне 32+, который указывает uops.info.


Глава 8. Какой именно регистр?

Но подождите. Зависимость по одному rdx (глава 5) не замедлила цикл, а зависимость по обоим — замедлила. Значит, дело конкретно в rax?

Проверяем три варианта:

; Вариант 1: зависимость только по raxadd rax, 536502          ; зависит от raxxor edx, edx             ; НЕ зависит от rdxdiv qword [best_den]; Вариант 2: зависимость только по rdxmov rax, 536700           ; НЕ зависит от raxadd rdx, -2100            ; зависит от rdxdiv qword [best_den]; Вариант 3: обаadd rax, 536502add rdx, -2100div qword [best_den]

Результаты:

┌────────────────┬─────────┬───────────────────┐│ Зависимость    │ cpr     │ Такты/итерацию    │├────────────────┼─────────┼───────────────────┤│ нет            │ ~48 000 │ ~24 (throughput)   │├────────────────┼─────────┼───────────────────┤│ только rax     │ 67 800  │ 34.3              │├────────────────┼─────────┼───────────────────┤│ только rdx     │ 46 250  │ 23.1              │├────────────────┼─────────┼───────────────────┤│ оба            │ 67 050  │ 34.0              │└────────────────┴─────────┴───────────────────┘

Зависимость по rax → 34 такта. Зависимость по rdx → 23 такта. Как будто зависимости нет.


Глава 9. Остаток приходит раньше частного

Делитель выдаёт результаты не одновременно.

Остаток (rdx) готов через ~23 такта — раньше, чем throughput делителя (~21). Поэтому зависимость по rdx ничего не стоит: к моменту, когда делитель готов принять следующую операцию, rdx уже давно посчитан.

Частное (rax) готов через ~34 такта — позже, чем throughput. Делитель свободен, add rax, 536502 готова к выполнению, но ей нужен rax, которого ещё нет. 10 тактов простоя.

Данные uops.info подтверждают: на Coffee Lake для DIV R64 latency до rax (частное) начинается от 32 тактов, а до rdx (остаток) — от 5 тактов.


Полная карта делителя

Данные uops.info для Coffee Lake:

┌────────────┬──────────────┬──────────────┬────────────┬───────┬─────────────┐│ Инструкция │ Lat →rax     │ Lat →rdx     │ Throughput │ µops  │ Из микрокода│├────────────┼──────────────┼──────────────┼────────────┼───────┼─────────────┤│ div r16    │ 21           │ 22           │ 6          │ 10    │ 7           │├────────────┼──────────────┼──────────────┼────────────┼───────┼─────────────┤│ idiv r16   │ 21           │ 22           │ 6          │ 10    │ 7           │├────────────┼──────────────┼──────────────┼────────────┼───────┼─────────────┤│ div r32    │ 24–26        │ 24–26        │ 6          │ 10    │ 7           │├────────────┼──────────────┼──────────────┼────────────┼───────┼─────────────┤│ idiv r32   │ 24–26        │ 24–26        │ 6          │ 10    │ 7           │├────────────┼──────────────┼──────────────┼────────────┼───────┼─────────────┤│ div r64    │ 32–87        │ 5–75         │ 21         │ 33    │ 32          │├────────────┼──────────────┼──────────────┼────────────┼───────┼─────────────┤│ idiv r64   │ 38–94        │ 39–95        │ 24         │ 56    │ 53          │└────────────┴──────────────┴──────────────┴────────────┴───────┴─────────────┘

16/32-бит: 10 µops, throughput 6. Зазор latency/throughput — ~3.7×. Partial register merge не даёт попасть в throughput, и мы видим latency.

64-бит div: 33 µops (32 из микрокода!), throughput 21. Другая реализация.

64-бит idiv: 56 µops. Знаковое 64-битное деление — самая тяжёлая целочисленная операция на Skylake.


Итоги

  1. Регистры — это псевдонимы. Физические регистры выделяются динамически через RAT. Это основа OoO-execution.

  2. 16-битные записи ядовиты. Они создают ложные зависимости через partial register merge. 32-битные записи обнуляют верх и разрывают цепочку. Причём никакой экономии нет: mov dx, imm16 всё равно занимает целый 64-битный физический регистр и добавляет merge µop сверху.

  3. Latency ≠ throughput. Делитель может принять новую операцию задолго до выдачи результата предыдущей. Но только если зависимости не блокируют подачу операндов.

  4. Разные выходы одной инструкции имеют разную latency. Остаток готов раньше частного. Зависимость от «быстрого» выхода может быть бесплатной.

  5. Размер операнда определяет реализацию. 16/32-бит div — 10 µops. 64-бит — 33 µops из микрокода. Это не масштабирование, это другой алгоритм.


Наверное вы успели заметить, что в одном случае 6 тактов, а в другом 7 тактов, хотя throughput — 6. Кто украл такт? И как вообще так получилось, что все, что вокруг деления на в одном случае ничего не стоит, а в другом случае обходится лишь в 1 такт. Если это будет интересно, то я обязательно освещу это в другой статье. Всем спасибо, что дочитали до конца!

Замеры выполнены на Intel Core i7-10510U (Comet Lake, 14nm, микроархитектура Skylake). Данные о latency и throughput — uops.info. Все эксперименты — чистый NASM x86-64, Linux/X11.

ссылка на оригинал статьи https://habr.com/ru/articles/1024862/