Як працює двійкова арифметика: цілі числа в додатковому коді та числа з рухомою комою IEEE-754

Кожна числова операція, яку виконує ваша програма — a + b, x * y, count / 2 — зрештою стає послідовністю операцій на бітовому рівні над бітовими шаблонами. Те, як ці операції з бітами перетворюються на правильний арифметичний результат, повністю залежить від того, яке кодування використовують біти.

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

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

Тут ми зосереджуємося виключно на самих арифметичних операціях із коротким повторенням кодування, де це необхідно.

Арифметика над двома доповненнями цілих чисел

Ми припустимо, що ви вже знаєте основи кодування — старший біт (MSB) має негативну вагу, щоб заперечити число, ви перевертаєте його біти та додаєте 1, а діапазон є асиметричним на одиницю (-8 до +7 у 4 бітах або від -2,147,483,648 до +2,147,483,647 у 32-бітах). Якщо щось із цього незнайоме, повне керівництво є в зсув двійкового проти двох доповнень. 4-бітні приклади, використані нижче, є лише заміною для будь-якої n-бітної ширини.

Додавання та віднімання: те саме, що беззнакове

Перша прекрасна властивість доповнення двох: додавання зі знаком використовує ту саму фізичну схему, що й додавання без знаку. Доповнення до двох зберігає кожне від’ємне число xx, використовуючи бітовий шаблон, який, коли читається як беззнаковий, дорівнює x+2nx + 2^n — наприклад, у 4 бітах (n=4n = 4), −3-3 зберігається як шаблон для 13: x+2n=−3+24=−3+16=13x + 2^n = -3 + 2^4 = -3 + 16 = 13, який є 1101 у двійковому вигляді. Через це додавання від’ємного та додатного чисел часто переповнює бітовий регістр nn, але апаратне забезпечення просто відкидає перенесення — і підписана відповідь випадає автоматично.

Подивимося на дію додавання від’ємного числа. Відстежте -3 + 5 у 4 бітах — -3 кодується як 1101 (= 13), 5 є 0101:

    1 1 0 1     (-3, stored as 13)
  + 0 1 0 1     (+5)
  ─────────
  1 0 0 1 0     (= 18; doesn't fit in 4 bits — the leading 1 is the carry-out, weight 2^4 = 16)
    0 0 1 0     (kept in the 4-bit register: 2)  ✓ matches -3 + 5 = 2

Апаратне забезпечення просто додало беззнакові бітові шаблони 13 + 5 = 18. Біт переносу на 5-й позиції (вага 24=162^4 = 16) не поміщався в 4-бітний регістр і був відкинутий. Цей відкинутий 16 є саме частиною +2n+2^n у формулі x+2nx + 2^n, яку ми бачили вище — зміщення кодування та переповнення скасовуються, і підписана відповідь виходить безкоштовно. Тепер ми просто інтерпретуємо ці біти як підписані, тоді як апаратне забезпечення не розрізняє. Така сама логіка стосується випадків відсутності переповнення: -7 + 4 є 1001 + 0100 = 1101, що декодується як -3 (зі знаком) або 13 (без знаку).

Оскільки віднімання — це просто додавання об’єднаного числа, воно не потребує окремої схеми або a - b = a + (-b), де заперечення — це рецепт перевернути біти та додати 1. Для 5 - 3: -3 є 1101, потім 0101 + 1101 = 10010. Початковий біт падає в 4-х бітах, залишаючи 0010 = 2 — цей випущений біт є модульною оболонкою. Повне кадрування (nn-бітова арифметика як коло позицій 2n2^n) проходить у модульно-арифметичний розділ статті кодування.

Зберігання молодших бітів nn дає обертання: найбільше значення зі знаком плюс одиниця стає найменшим. Правила мови можуть відрізнятися від цього апаратного результату: переповнення зі знаком C/C++ не визначено, а арифметика без знака обертається. INT_MAX і INT_MIN описують діапазон int реалізації, не обов’язково 32 біти. Python int і JavaScript BigInt мають довільну точність; JavaScript Number використовує плаваючу кому.

Порівняння: де одних бітів недостатньо

На відміну від додавання, порівняння залежить від інтерпретації. Бітовий шаблон 1111 є 15 як беззнаковий, тому природно 1111 > 0001 (15 > 1). Однак, якщо ті самі біти мають бути -1 зі знаком, правильною відповіддю буде -1 < 1, але порівняння на рівні бітів все одно дає 1111 > 0001. Ті самі частини, протилежні відповіді.

На апаратному рівні порівняння часто реалізується як віднімання: ЦП обчислює a - b і встановлює прапорці (знак, нуль, переповнення, перенесення); інструкції з розгалуження, а потім прочитайте ці позначки, щоб вирішити результат розгалуження. Саме віднімання використовує той самий механізм a + (-b) зверху — ЦП просто відкидає результат і зберігає лише прапорці. Кілька 4-бітних прикладів, щоб побачити кожен прапор у дії: у прикладах використовується угода x86: прапор переносу вказує на запозичення при відніманні.

 5 - 3:   0101 - 0011 = 0010    carry=0, sign=0, zero=0, overflow=0
 3 - 5:   0011 - 0101 = 1110    carry=1, sign=1, zero=0, overflow=0    (= -2 signed)
 5 - 5:   0101 - 0101 = 0000    carry=0, sign=0, zero=1, overflow=0
-8 - 1:   1000 - 0001 = 0111    carry=0, sign=0, zero=0, overflow=1    (= -9, doesn't fit)

Візьміть перший рядок, 5 - 3 = 2, де кожен прапорець 0: carry = 0, оскільки віднімання без знака залишається в діапазоні (5 ≥ 3); sign = 0, тому що MSB результату (0010) дорівнює 0; zero = 0, оскільки результат відмінний від нуля; overflow = 0, оскільки +2 легко вписується в 4-бітовий діапазон зі знаком. Інші рядки ілюструють прапори: carry спрацьовує, коли віднімання без знаку переходить після нуля (рядок 2: 3 < 5, отже, результатом є кодування доповнення до двох від’ємного числа); sign спрацьовує, коли MSB результату 1 (у рядку 2 1110); zero спрацьовує, коли операнди рівні (рядок 3); overflow спрацьовує, коли результат зі знаком випадає з 4-бітового діапазону зі знаком (рядок 4: -8 - 1 = -9 переповнюється нижче мінімального -8).

Оскільки ті самі біти можуть означати різні речі зі знаком і без знаку, процесори надають дві групи інструкцій розгалуження — підписані (JL, JG на x86 — «перейти, якщо менше/більше») і беззнакові (JB, JA — «перейти, якщо нижче/вище») — кожна з них читає різні комбінації прапорів:

ПорівнянняБез підписуПідписано
a < bJB: перенос = 1JL: знак ⊕ переповнення = 1
a > bJA: перенос = 0 ∧ нуль = 0JG: знак ⊕ переповнення = 0 ∧ нуль = 0
a ≤ bJBE: перенос = 1 ∨ нуль = 1JLE: знак ⊕ переповнення = 1 ∨ нуль = 1
a ≥ bJAE: перенос = 0JGE: знак ⊕ переповнення = 0

В умовах ⊕ — логічне ІКС-АБО (рівно один вхід — 1), ∧ — логічне І (обидва входи — 1), а ∨ — логічне АБО (принаймні один вхід — 1). Компілятор вибирає правильний варіант із заявленого типу.

У дії, знову в 4-бітах (умова прапора кожного рядка задовольняється, тому гілка виконується):

  JB  (1 < 2  unsigned):   0001 - 0010 = 1111    carry = 1
  JA  (3 > 2  unsigned):   0011 - 0010 = 0001    carry = 0, zero = 0
  JL  (-1 < 1 signed):     1111 - 0001 = 1110    sign ⊕ overflow = 1 ⊕ 0 = 1
  JG  (3 > 2  signed):     0011 - 0010 = 0001    sign ⊕ overflow = 0 ⊕ 0 = 0, zero = 0

Права частина кожного рядка читається як formula → substituted flag values → boolean result. Візьміть рядок JL: віднімання 1111 - 0001 = 1110 має sign = 1 (MSB результату) і overflow = 0 (значення зі знаком -2 все ще вписується в 4-біт), тому sign ⊕ overflow стає 1 ⊕ 0, що оцінюється як 1 — умова стрільби JL. Ряд JG працює так само з sign = 0 і overflow = 0, даючи 0 ⊕ 0 = 0 (плюс zero = 0), що є умовою стрільби JG.

Рівність (a == b, інструкція JE) та її заперечення (JNE) просто зчитують нульовий прапор і є ідентичними для знакового та беззнакового.

Множення: зсув і додавання

Згадайте довге множення з початкової школи: кожну цифру одного операнда помножте на інший операнд, зсув отриманого рядка на позицію цифри та підсумуйте всі рядки. Наприклад, 123 × 456:

        1 2 3              (a = 123)
      × 4 5 6              (b = 456)
      ─────────
        7 3 8              ← 123 × 6 (6 is at position 0, no shift)
      6 1 5                ← 123 × 5, shifted one position left (5 is at position 1)
    4 9 2                  ← 123 × 4, shifted two positions left (4 is at position 2)
    ─────────
    5 6 0 8 8              = 56088

Двійкова система працює так само, але з одним великим спрощенням: кожна цифра множника є 0 або 1. Таким чином, кожен рядок є або зміщеною копією множеного a (коли множник дорівнює 1), або просто нулем (якщо біт дорівнює 0) — немає кроку множення цифра на цифру, як у 10. Це так, ніби кожне множення виконується на число з основою 10, цифри якого просто 1s і 0s — наприклад 25 × 101:

        2 5               (a = 25)
      × 1 0 1             (b = 101)
      ─────────
        2 5               ← digit 0 = 1: add 25
      0 0                 ← digit 1 = 0: skip
    2 5                   ← digit 2 = 1: add 25 shifted 2 left (= 2500)
    ─────────
    2 5 2 5               = 2525

Зверніть увагу, що фактичного кроку множення цифр немає — кожен рядок або 25 зсувається на свою позицію, або просто 0. А сам зсув — це просто доповнення нуля в нижньому кінці: 25, зміщений на 2 вліво, є 2500 із двома доданими нулями (у двійковому такому самому зсуві 0011 перетворюється на 1100). Тож уся операція зводиться до «або додати копію a з доповненням нуля, або пропустити». Це властивість, яка робить двійкове множення таким дешевим: у двійковому множнику завжди є цифри в {0, 1}, тому кожен рядок згортається до того самого вибору зсуву або нуля, незалежно від операнда.

Ось 3 × 5 (0011 × 0101) у 4 бітах, запустіть за тим самим алгоритмом:

        0 0 1 1           (a = 3)
      × 0 1 0 1           (b = 5)
      ─────────
        0 0 1 1           ← bit 0 = 1: add a
      0 0 0 0             ← bit 1 = 0: skip
    0 0 1 1               ← bit 2 = 1: add a ≪ 2
  0 0 0 0                 ← bit 3 = 0: skip
  ─────────────────
  0 0 0 0 1 1 1 1         = 15

Розкладка рядків пропонує n послідовні кроки, але ЦП не потребує їх послідовності. Кожен рядок залежить лише від a і одного біта b, а не від будь-якого результату попереднього рядка, тому всі рядки n можна обчислювати паралельно. Щоб створити рядок i, апаратне забезпечення зсуває a вліво на i (позиція рядка), потім або зберігає зміщене значення (якщо b[i] дорівнює 1), або замінює його всіма нулями (якщо b[i] дорівнює 0). Часткові добутки n потім підсумовуються паралельним деревом суматора (Уоллес або Тато), який бере затримку затвора O(log⁡n)O(\log n) — на відміну від циклів O(n)O(n) для послідовного множника зсуву та додавання.

В апаратному забезпеченні це стає паралельним деревом: b розбивається на свої біти, кожен біт є гілкою, і кожна гілка вирішує 0 або 1. Якщо 1, ця гілка вносить a, зміщений на її позицію; якщо 0, внесок нульовий. Усі гілки працюють паралельно, потім суматор дерева підсумовує їх.

Для a = 0011 (= 3), b = 0101 (= 5) процес обчислення виглядає так:

              a = 0011 (broadcast to every branch)
              │
  ┌───────────┼───────────┬───────────┬───────────┐
  │           │           │           │           │
  ▼           ▼           ▼           ▼
a ≪ 0       a ≪ 1       a ≪ 2       a ≪ 3            ← shift by branch index
= 00000011  = 00000110  = 00001100  = 00011000        (result in 8-bit field)
  │           │           │           │
  × b₀=1      × b₁=0      × b₂=1      × b₃=0          ← gate with that b bit
  │           │           │           │                 (if bᵢ=0, output = 0;
  ▼           ▼           ▼           ▼                 if bᵢ=1, output = a≪i)
00000011    00000000    00001100    00000000
(partial₀)  (partial₁)  (partial₂)  (partial₃)
  │           │           │           │
  └───────────┴─────┬─────┴───────────┘
                    │
                    ▼
       ┌────────────────────────────┐
       │  parallel adder tree       │   ← O(log n) gate depth
       │  sum = 00001111 = 15       │
       └──────────────┬─────────────┘
                      │
                      ▼
                  P = 00001111 = 15                ← 2n-bit product (= a × b)

Існує практична пастка, про яку варто знати — множення може мовчки переповнюватись, навіть коли операнди не наближаються до максимального значення типу. Це тому, що продукт потребує 2n2n бітів, а не nn — для 4-біт × 4-біт найбільшим продуктом є 15×15=225=1110000115 \times 15 = 225 = \texttt{11100001}, який розливається на 8 бітів; для 8-біт × 8-біт 255×255=65,025255 \times 255 = 65{,}025 потребує 16 біт. Різні мови обробляють це по-різному:

СтратегіяПоведінка при переповненніПриклад
Безшумне усіканняЗалиште низькі біти n, відкиньте високу половинуJava int * int → int; Rust i32 * i32 у випуску
Більш широкий типСпочатку просунути операнди, щоб усі біти 2n відповідалиC: (int64_t)a * (int64_t)b; Java: (long)a * b
Перевірено / відловВиявлення переповнення, повернення помилки або панікиІржа a.checked_mul(b) → Option; Режим налагодження Rust * панікує
насиченийЗатискач до MAX/MIN типу замість обгортанняІржа a.saturating_mul(b)
Довільно-точнийВикористовуйте тип, який росте — взагалі без переповненняPython int, JavaScript BigInt, Java BigInteger

Множення зі знаком

На відміну від додавання, множення знакових і беззнакових не працює так просто з тим самим алгоритмом. Це тому, що в доповненні до двох MSB множника має негативну вагу — тому, коли це 1, внесок цього рядка слід відняти, а не додати. Наприклад, з b = 1110 і a = 3: читається як беззнаковий, b = 14 і всі рядки додаються (0 + 6 + 12 + 24 = 42, що відповідає 3 × 14); читається як підписаний, b = -2 і рядок MSB замість цього віднімається (0 + 6 + 12 − 24 = -6, що відповідає 3 × -2). Ті самі біти операнда, знак одного рядка перевернуто.

Отже, необхідно внести кілька змін у наведений вище алгоритм: відняти (а не додати) частковий добуток для рядка знакових бітів і знаково розширити кожен частковий добуток до повної ширини 2n2n-бітів, щоб негативні внески правильно поширювалися у верхню половину. Запуск 3 × -2 (0011 × 1110) за цим рецептом:

        0 0 1 1                  (a = 3)
      × 1 1 1 0                  (b = -2 signed)
      ─────────
  0 0 0 0 0 0 0 0    ← bit 0 = 0: skip
  0 0 0 0 0 1 1 0    ← bit 1 = 1: add a ≪ 1
  0 0 0 0 1 1 0 0    ← bit 2 = 1: add a ≪ 2
  1 1 1 0 1 0 0 0    ← bit 3 = 1 (MSB!): subtract a ≪ 3 (= -24, encoded in 8-bit two's complement)
  ─────────────────
  1 1 1 1 1 0 1 0    = -6 (signed)  ✓

Розбіжність зі знаком/беззнаком повністю міститься у старших бітах nn повного продукту 2n2n — молодші біти nn однакові в будь-якому випадку. Наприклад, з однаковими бітами операнда 0011 × 1110:

  unsigned (3 × 14 = 42):   0 0 1 0  |  1 0 1 0
  signed   (3 × -2 = -6):   1 1 1 1  |  1 0 1 0
                            ───────     ───────
                            upper n     low n
                            (differs)   (identical)

Таким чином, біти добутку з низьким рівнем nn не потребують окремих схем множення зі знаком і без знаку. Перевірки переповнення та правила мови все ще відрізняються; це не робить підписане переповнення, визначене в C/C++.

Ділення: зсув і віднімання

Давайте тепер подивимося на поділ. На бітовому рівні в цьому немає нічого екзотичного — просто довге ділення, застосоване до двійкового. Згадайте довге поділ у шкільному стилі, скажіть 105 ÷ 7:

      1 5
    ─────
7 │ 1 0 5
      7
    ─────
      3 5
      3 5
    ─────
        0

Щоб обчислити, ми ходимо цифрами дивіденду (105) зліва направо. На кожному кроці ми запитуємо: «Скільки разів дільник (7) вписується в те, що ми маємо на даний момент?», віднімаємо це багато разів, опускаємо наступну цифру та повторюємо.

Двійкове ділення працює точно так само, за винятком “скільки разів?” запитання має лише дві можливі відповіді: 0 або 1 — дільник або підходить один раз, або не підходить взагалі. Це ключове спрощення. У базі 10 для кожного кроку потрібен фактичний обчислення (або оцінка), щоб визначити цифру від 0 до 9 — вам потрібно визначити, яка частина дільника підходить, що, як правило, вимагає множення та методу проб і помилок.

За основою 2 немає чого обчислювати: одне порівняння між поточним залишком і дільником дає відповідь безпосередньо — 1, якщо дільник підходить, 0, якщо ні. Жодної таблиці множення, жодних методів проб і помилок, лише одне рішення «віднімати або не робити» на біт. Ось чому двійкове довге ділення так чітко відображається на апаратному забезпеченні: цикл верхнього рівня повторює біти ділення, тоді як кожна ітерація є лише компаратором та умовним відніманням — нічого більше.

І згадайте з розділу порівняння, що компаратор реалізовано як віднімання: R ≥ D це просто R − D із перевіркою знака/прапорця переносу. Отже, для кожної ітерації циклу апаратне забезпечення спекулятивно обчислює R − D: якщо результат є невід’ємним (без запозичення), воно повертає цей результат до R і записує 1 у Q; інакше він відкидає результат і записує 0. Завжди одне віднімання за ітерацію.

ЦП зберігає два регістри: R зберігає поточний залишок, Q накопичує частку порозрядно. На кожному кроці він зсуває R ліворуч і вводить наступний біт дільника, а потім порівнює R з D, завжди обчислюючи R − D. За результатами:

  • якщо R − D є невід’ємним (D підходить) → встановлює новий біт Q на 1 і замінює R цим результатом
  • якщо R − D негативний (D не підходить) → встановлює новий біт Q на 0, скидає результат і залишає R без змін

Давайте подивимося на приклад: 13 ÷ 3 в 4 бітах, ділене 1310=1101213_{10} = 1101_2, дільник 310=001123_{10} = 0011_2. Перейдіть за допомогою віджета нижче, щоб побачити обидва відтворення паралельно — довгий розподіл у шкільному стилі ліворуч і трасування регістру зсуву ЦП праворуч. Віджет показує чотири рядки регістра: Ds — це дільник (те, що ми називаємо D у прозі), Dd — це ділене, R — поточний залишок, а Q — це частка, яка будується порозрядно. Ментальна модель: Dd — це вхідний потік бітів, що подається в R (наступний біт дільника зміщується на кожному такті); Ds — статичне посилання, з яким порівнюється R; R і Q є робочими регістрами, обидва зсуваються ліворуч за кожну позначку.

School long division
CPU shift-register trace
Ds
0011
= 3
Dd
1101
= 13
R
0000
= 0 (remainder)
Q
0000
= 0 (quotient)
 
if R < Ds
no subtract · Q bit = 0
if R ≥ Ds
subtract (R ← R − Ds) · Q bit = 1
 
Step 0 / 13

Наведений вище віджет має 14 станів — узгоджену структуру з 3 підетапів на клік (зсув R, порівняння/віднімання, зсув Q). Трасування нижче відображає покроковий текст, показаний на інформаційній панелі віджета:

Step 0   — Init. Ds = 0011 (3), R = 0000, Q = 0000.

Tick 1 — bit 3 = 1 (no subtract)
    Step 1   — (a) shift R: bit 3 shifted in → R = 0001. No compare yet.
    Step 2   — (b) compare: 1 < 3 → no subtract. "R < D" branch lit.
    Step 3   — (c) shift Q: Q = 0000. First quotient digit = 0.

Tick 2 — bit 2 = 1 (subtract fires)
    Step 4   — (a) shift R: bit 2 shifted in → R = 0011 (intermediate, visible).
    Step 5   — (b) compare: 3 ≥ 3 → "R ≥ D" branch lit; will subtract on the next click.
    Step 6   — (c) subtract + shift Q: R = 0011 − 0011 = 0000; Q = 0001. School-side "1 1" row appears.

Tick 3 — bit 1 = 0 (no subtract)
    Step 7   — (a) shift R: bit 1 shifted in → R = 0000.
    Step 8   — (b) compare: 0 < 3 → no subtract.
    Step 9   — (c) shift Q: Q = 0010. Third quotient digit = 0.

Tick 4 — bit 0 = 1, LSB (no subtract)
    Step 10  — (a) shift R: bit 0 shifted in → R = 0001.
    Step 11  — (b) compare: 1 < 3 → no subtract.
    Step 12  — (c) shift Q: Q = 0100. Fourth quotient digit = 0.

Step 13  — Done. Q = 0100 (= 4), R = 0001 (= 1). 13 ÷ 3 = 4 remainder 1.

Після n кроків (один на біт) ви маєте повний коефіцієнт. Незалежно від Q і R, на які ЦП приземляється в кінці циклу, вони задовольняють жорстку ідентичність за конструкцією:

a=q⋅b+r,q,r∈Zfor any b≠0a = q \cdot b + r, \quad q,r \in \mathbb{Z} \quad \text{for any } b \neq 0

Ідентичність — це не додаткове правило — це визначення залишку: R — це все, що залишилося після того, як цикл віднімає D стільки разів, скільки можливо. Q = 4, R = 1 віджета є тривіальною перевіркою: 4 · 3 + 1 = 13. Shift-and-subtract створює як a / b, так і a % b за один прохід — обидва результати випадають разом.

Реалізації зберігають цю ідентичність скрізь, де Q і R обидва вписуються в реєстр. Якщо справжнє приватне не є цілим числом, його потрібно округлити — і мови використовують два підходи:

  • Укорочення до нуля (C99/C++, Rust, Java, Go): a / b округляє до нуля; залишок успадковує знак діленого.
  • Поверхове поділ (Python): a // b обертається в бік −∞-\infty; остача успадковує знак діленого.

Приклад із a = -17, b = 5 (справжня частка −3.4-3.4):

C:       -17 / 5  = -3     -17 % 5 = -2    (-3) * 5 + (-2) = -17   ✓
Python:  -17 // 5 = -4     -17 % 5 =  3    (-4) * 5 +  3   = -17   ✓

Обидва задовольняють ідентичність; вони відрізняються лише напрямком округлення. Алгоритм зсуву та віднімання, наведений вище, створює усічений ділення напряму — він працює з абсолютними значеннями та повторно застосовує знак на межі. Поверхове ділення є невеликим посткоригуванням: якщо знаки операндів відрізняються, а залишок відмінний від нуля, зменшіть частку на 1 і додайте b до залишку.

Цей алгоритм зсуву та віднімання потребує nn ітерацій для nn-бітового дільника. Це враховує ітерації, а не окремі бітові операції: кожне порівняння та віднімання також має вартість. Апаратні розділювачі можуть використовувати швидші алгоритми, а їх затримка залежить від ЦП і операндів.

Для ділення зі знаком із скороченням до нуля розділіть величини, від’єднайте частку, якщо знаки відрізняються, і дайте ненульовому залишку знак діленого. Підрозділ підлоги Python додатково потребує налаштування, описаного вище; лише зміни знака залишку недостатньо.

Це добре працює з кожною парою, за винятком одного крайнього корпусу.

Ділення з одним знаком, яке переповнюється

Для ділення зі знаком із фіксованою шириною на ненульове значення переповнюється рівно одна пара операндів: мінімальне значення, поділене на -1. Для 32-розрядного типу зі знаком:

INT_MIN÷−1=−INT_MIN=+231\text{INT\_MIN} \div -1 = -\text{INT\_MIN} = +2^{31}

Це точно INT_MAX + 1, або (2^31 − 1) + 1 = 2^31, яке на одиницю вище найбільшого значення зі знаком, яке можна представити. Результат буквально не може бути представлений у будь-якій 32-бітовій двокомплементній бітовій моделі. Немає короткого пояснення, яке б дало правильну відповідь — 2312^{31} просто не існує у форматі.

Це прямий наслідок асиметричного діапазону. Кожне від’ємне ціле число, крім INT_MIN, має відповідне додатне; INT_MIN є єдиним винятком. Спроба заперечити його (через унарний - або через ділення на -1) запитує значення, яке формат не може створити.

Різні системи справляються з цим по-різному:

системаПоведінка
Процесори x86 (безпосередньо)Виняток переповнення розділу (збій програми через SIGFPE в Unix)
C / C++Невизначена поведінка — компілятор може припустити, що цього ніколи не станеться
JavaВизначено: Integer.MIN_VALUE / -1 == Integer.MIN_VALUE (загортається в себе)
PythonНе проблема — int є довільною точністю
Rust (налагодження)Паніка з повідомленням про переповнення
Rust (реліз)Паніка для MIN / -1, навіть якщо перевірки переповнення вимкнено

Усі вони повинні зробити вибір, тому що математична відповідь просто не вписується в бітовий шаблон — немає правильного резервного варіанту.

Арифметика на IEEE-754 з плаваючою точкою

Арифметика з поплавком — зовсім інша тварина. Замість чистого модульного світу двох доповнень IEEE-754 дає вам сітку репрезентованих значень, відстань між якими подвоюється кожного разу, коли експонента збільшується — щільна біля нуля, розріджена біля крайніх значень. Кожна операція має вирівнювати експоненти, виконувати основні обчислення, повторно нормалізувати результат і округляти, щоб відповідати мантисі.

Ми припустимо, що ви вже знаєте двійкову схему64 IEEE-754 — 1 знаковий біт, 11 зміщених бітів експоненти, 52 біти мантиси, неявний початок 1. Якщо це нове, повна розбивка в як JavaScript Number і float Python зберігають числа. Тут ми продовжуємо те місце, де ця стаття закінчується — формат фіксований; тепер що станеться, коли ви обчислите два з цих чисел.

Коротка замітка щодо термінології

Один термін, який з’являється нижче, це субнормальний (іноді його називають денормальний): дуже маленьке плаваюче значення, менше найменшого нормального значення 2−10222^{-1022}. Нормальні плаваючі значення мають форму 1.xxx × 2e2^e з неявним провідним 1; субнормальні відкидають цей неявний 1, закодований експонентою, що складається з усіх нулів, і ненульовою мантисою. Це дозволяє IEEE-754 поступово відображати значення приблизно до 2−10742^{-1074} замість стрибка від 2−10222^{-1022} прямо до нуля — ця функція називається поступове недоплив.

Покрокове керівництво нижче передбачає звичайні кінцеві операнди та округлення до найближчого, зв’язки до парного. Він розділяє знак, експоненту та обробку значущого, а потім нормалізує та округлює до 53 значущих бітів (52 збережених біта дробу). Нуль, субнормалі, нескінченності, NaN і переповнення вимагають додаткових випадків.

Для множення та ділення операнди вже мають форму 1.xxx × 2^e, тому операція виконується безпосередньо: знаки обробляються XOR, експоненти додаються (mul) або віднімаються (div), а мантиси множаться або діляться. Три кроки: operate → normalize → round.

Для додавання та віднімання є додатковий крок преамбули — вирівнювання — оскільки для додавання значень потрібно, щоб вони були в одному масштабі. Мантиса меншого операнда зсувається праворуч, поки обидва показники не збігаються, і лише тоді мантиси об’єднуються; знак результату походить від операнда більшої величини. Чотири кроки: align → operate → normalize → round.

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

Додавання: вирівняти, додати, нормалізувати, округлити

Додавання двох чисел з плаваючою точкою складніше, ніж додавання двох цілих чисел. П’ять кроків:

  1. Порівняйте показник степеня. Виберіть більший показник степеня.
  2. Вирівняйте мантиси. Змістіть мантису меншого операнда вправо, щоб обидві мантиси представляли значення в одному показнику степеня. Це може змістити біти з правого кінця (які стають захисними, круглими та липкими бітами, які використовуються для правильного округлення).
  3. Додайте вирівняні мантиси. Просте двійкове додавання двох (включно з неявними початковими 1).
  4. Повторно нормалізуйте. Якщо сума переповнилася в наступний експонент (наприклад, 1.1 + 1.1 = 11.0, потрібен 1.10 × 2^1), перемістіть мантису праворуч і підштовхніть експоненту. Якщо сума менша за нормалізовану форму (скасування віднімання), зсуньте ліворуч і зменште експоненту.
  5. Кругла. Повторно нормалізована мантиса зазвичай має більше бітів, ніж дозволяє 52-бітне поле. Округліть до найближчого значення, яке можна представити, використовуючи стандартне значення IEEE-754 округлення до найближчого, пов’язане з парним.

Ми можемо побачити, як застосовуються всі ці кроки, детально прослідкувавши відомий випадок, коли 0.1 + 0.2 обчислюється як 0.30000000000000004 замість 0.3. І 0.1, і 0.2 є нескінченними повторюваними двійковими кодами (так само, як 1/3 є 0.333… у десятковій системі: 0.1=0.00011‾20.1 = 0.0\overline{0011}_2), тому кожен округлюється до 52 бітів мантиси під час збереження, а додавання округлюється знову. Три округлення, помилки яких не скасовуються. докладний крок за кроком є в статті-браті.

Цей приклад показує, що додавання не обов’язково має бути точним. Додавання також не повинно бути асоціативним: у двійковому 64 (1e16 + -1e16) + 1 є 1, тоді як 1e16 + (-1e16 + 1) є 0, оскільки внутрішня сума втрачає 1 через округлення.

Нейронні мережі виконують величезну кількість цих додавання: навіть матричне множення обчислює кожен вихід шляхом підсумовування продуктів. Таким чином, зміна способу групування цих сум може змінити числові результати моделі. Подолання недетермінізму в висновках LLM показує, як зміна кількості запитів, що обробляються разом — розмір пакету — може змінити порядок, у якому механізм висновку підсумовує значення з плаваючою комою. Отримані різниці округлення поширюються в моделі та можуть змінити, який токен має найвищий бал, коли кандидати близькі. Таким чином, одна і та сама підказка та модель можуть створити різний текст, оскільки математично еквівалентні обчислення округлюються по-різному.

Віднімання: катастрофічна пастка скасування

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

Коли віднімаються два майже рівні числа з плаваючою точкою, початкові біти скасовуються. Крок повторної нормалізації потім зсувається ліворуч на багато позицій, «підтягуючи» молодші біти, які спочатку були найменш значущими (і, отже, найменш точними) частинами операндів.

Приклад у binary64: два дублі, які відрізняються лише останнім бітом мантиси:

  • a = 1.0000…0001 × 2⁰ (мантиса: 51 нуль, потім 1; дорівнює 1 + 2⁻⁵²)
  • b = 1.0000…0000 × 2⁰ (мантиса: усі нулі; дорівнює 1.0)

Тут a − b = 2⁻⁵² точний: скасування саме по собі не означає помилки округлення. Небезпека виникає, якщо операнди вже містять помилки з попередніх обчислень. Їх невелика різниця може мати велику відносну похибку.

Ось чому числовий код часто змінює структуру формул, щоб уникнути віднімання майже рівних значень — скасування може перетворити невелику помилку округлення на результат без дійсних цифр.

Порівняння: бітовий порядок працює (переважно)

Для невід’ємних скінченних двійкових64 значень беззнаковий порядок бітових шаблонів відповідає числовому порядку. Для від’ємних значень впорядкування величин має бути протилежним; порівняння між знаками також потребують обробки знаків. Тому необроблене беззнакове порівняння не працює для всіх плаваючих значень.

Але два особливі випадки порушують просте правило:

  • NaN порівнюється з усім, включаючи себе. NaN == NaN оцінюється як false. Це зроблено навмисно — NaN представляє «недійсне число», тому воно не може дорівнювати нічому. Алгоритми сортування та хеш-таблиці повинні спеціально обробляти NaN.
  • +0 == -0, незважаючи на те, що біти знаків відрізняються. Два нулі порівнюються чисельно, але вони не є повністю взаємозамінними: 1 / +0 дає +∞, а 1 / -0 дає -∞.

[NaN].includes(NaN) JavaScript повертає true, оскільки він використовує SameValueZero, який розглядає NaN як рівні, а також прирівнює +0 і -0. Це не порозрядна рівність. [NaN].indexOf(NaN) повертає -1, оскільки використовує сувору рівність.

Множення: простіше, ніж додавання

Дивно, але множення з плаваючою точкою простіше, ніж додавання, тому що вам не потрібно вирівнювати експоненти.

Щоб обчислити a × b:

  1. Помножте мантиси (включно з неявними початковими 1). Кожна мантиса має принаймні значення 1 (оскільки неявний провідний біт дорівнює 1) і менше 2 (будь-що ≥ 2 було б перенормовано до вищого показника). Отже, їхній добуток становить щонайменше 1 і менше 4 (мінімум 1 × 1 = 1; максимум трохи менше 2 × 2 = 4), тобто результат або вже нормалізований (1.xxx), або на один біт завеликий (1x.xxx).
  2. Додайте справжні показники степеня. Зміщені показники степеня повинні бути спочатку незміщеними, а потім повторно зміненими: (e1−1023)+(e2−1023)+1023=e1+e2−1023(e_1 - 1023) + (e_2 - 1023) + 1023 = e_1 + e_2 - 1023.
  3. XOR знакові біти. Позитивний × негативний = негативний тощо.
  4. Повторно нормалізуйте, якщо потрібно. Якщо добуток мантиси переповнився до 1x.xxx, зсуньте вправо на 1 і додайте 1 до експоненти.
  5. Округліть мантису, щоб умістити 52 біти.

І додавання, і множення округлюють свій точний результат один раз до цільового формату. Вирівнювання не додає окремого кроку округлення, якщо захисна, кругла та закріплена інформація зберігається правильно. Жодна з операцій не є більш точною для довільних вхідних даних.

Ми можемо побачити, як ці кроки застосовуються, дотримуючись 0.1 × 0.2, який оцінюється як 0.020000000000000004 замість 0.02. І 0.1, і 0.2 округлюють до тієї самої 52-бітної мантиси (того самого нескінченно повторюваного двійкового коду, який ми бачили раніше), відрізняючись лише експонентою:

  • 0.1 ≈ 1.10011001…10011010 × 2−42^{-4} (52 біти)
  • 0.2 ≈ 1.10011001…10011010 × 2−32^{-3} (52 біти)

Виконання п’яти кроків:

  1. Помножте мантиси. Кожна мантиса декодується приблизно до 1.6 у десятковій системі (двійковий 1.10011001…10011010 є найближчим 53-бітним наближенням 8/5 = 1.6). Повне множення двох 53-бітних значень дає до 106 бітів із значенням ≈ 1.6 × 1.6 = 2.56. Оскільки 2.56 ≥ 2, результат має форму 1x.xxx і потрібна перенормування.
  2. Додайте справжні показники: −4+−3=−7-4 + -3 = -7.
  3. XOR знаки: позитивний × позитивний = позитивний.
  4. Перенормуйте. Добуток мантиси має форму 1x.xxx, тому перемістіть праворуч на 1 (мантиса стає ≈ 1.28) і змініть експоненту від −7-7 до −6-6.
  5. Округліть 106-бітний добуток мантиси до 53 значущих бітів (52 збережених біта плюс неявний початковий біт).

Кінцеве збережене значення декодується до точного десяткового 0.02000000000000000388578058618804789148271083831787109375 — близького до 0.02, але не рівного. Помилки округлення в 0.1 і 0.2 переносяться в продукт, а сам продукт округлюється знову.

Поділ: рідко точний

Ділення з плаваючою точкою відображає п’ятиступеневу структуру множення, де операції виконуються на протилежні:

  1. Розділіть мантиси. Кожна мантиса принаймні 1 і менша за 2, тому мантиса частки принаймні 0.5 і менша за 2 — або вже нормалізована (1.xxx), або на один біт замала (0.xxx).
  2. Відніміть істинні експоненти. Той самий танець un-bias-then-rebias, що й множення: (e1−1023)−(e2−1023)+1023=e1−e2+1023(e_1 - 1023) - (e_2 - 1023) + 1023 = e_1 - e_2 + 1023.
  3. XOR знакові біти. Негативний/позитивний = негативний тощо.
  4. Повторно нормалізуйте, якщо потрібно. Якщо мантиса частки дорівнює 0.xxx, зсуньте ліворуч на 1 і зменште експоненту.
  5. Округліть мантису, щоб умістити 52 біти.

Велика відмінність від множення: ділення рідко буває точним. Два 53-розрядних значення можуть створити приватне, яке потребує нескінченно багато бітів для представлення — навіть якщо обидва операнди прості. Наприклад, 1.0 / 3.0 є двійковим еквівалентом 1/3 = 0.333… у десятковій системі: нескінченний повторюваний двійковий код, який потрібно округлити. Таким чином, крок 5 майже завжди виконує округлення, навіть якщо округлення не потрібне для операндів.

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

Переповнення та переповнення: нескінченність і нуль замість обертання

На відміну від цілих чисел із двома доповненнями, числа з плаваючою речовиною не обертаються при переповненні. IEEE-754 резервує спеціальні шаблони бітів для «занадто великих» і «занадто малих» результатів:

  • Переповнення: для округленого результату потрібен експонент, вищий від максимального, 1023 для binary64. Під округленням до найближчого переповнення дає ±∞; спрямовані режими можуть замість цього повертати найбільшу кінцеву величину, (2−2−52)21023(2-2^{-52})2^{1023}, з відповідним знаком.
  • Крихітні результати: нижче найменшої нормальної величини, 2−10222^{-1022}, субнормальні значення зберігають певну точність до 2−10742^{-1074}; менші результати можуть округлятися до нуля. Точний субнормальний результат не обов’язково піднімає позначку нижнього потоку.

У бітовому розташуванні (знак | 11 бітів експоненти | 52 біти мантиси):

+∞          :  0 | 11111111111 | 0000…0000
-∞          :  1 | 11111111111 | 0000…0000
+0          :  0 | 00000000000 | 0000…0000
-0          :  1 | 00000000000 | 0000…0000
subnormal   :  s | 00000000000 | xxxx…xxxx   (non-zero mantissa; no implicit leading 1)
NaN         :  s | 11111111111 | xxxx…xxxx   (non-zero mantissa)

Наприклад, у випадку округлення до найближчого 1e300 * 1e300 створює нескінченність, а 1e-300 * 1e-300 округлює безпосередньо до нуля. Поступове зниження описує доступні субнормальні значення, а не послідовність проміжних значень, які відвідує кожна операція.

Бітові шаблони для спеціальних значень знаходяться на крайніх значеннях діапазону експонент, тому для поля експоненти в першу чергу було обрано двійковий зсув — він розміщує шаблони експонент із усіма нулями та всіма одиницями на межах, де резервування є природним.

Ділення на нуль: нескінченність, не виняток

Цілочисельне ділення на нуль у більшості мов створює виняток. Ділення з плаваючою точкою на нуль не діє — воно створює ±Infinity:

> 1 / 0
Infinity
> -1 / 0
-Infinity

Python є винятком (він викликає ZeroDivisionError на мовному рівні), але базова арифметика IEEE-754 створює Infinity; Python просто перехоплює його. У JavaScript ви отримуєте необроблену поведінку.

0 / 0 відрізняється — він створює NaN, спеціальне значення «не число», яке поширюється через подальшу арифметику та порівнює нерівність із усім, включаючи себе.

Чим дві системи відрізняються

З обома системами викладені контрасти вражаючі. Цілочисельна арифметика та арифметика з плаваючою точкою не мають майже нічого спільного на бітовому рівні, і відмінності мають значення на практиці:

АспектДвоє доповнення фіксованої шириниIEEE-754 binary64
ІнтервалОдин між сусідніми цілими числамиЗмінюється в залежності від експоненти; постійна серед субнормальних
Додавання і множенняТочно, коли в зоні дії; залежне від мови переповненняПравильно округлений до обраного формату
ПереливМоже загортатися, перехоплюватися або бути невизначенимНескінченність або найбільше кінцеве значення, залежно від режиму округлення
Ділення на нульЗалежна від мови поведінка помилокНескінченність для кінцевого ненульового дивіденду за умовчанням без перехоплення; 0/0 дає NaN
НульОдне кодування+0 і -0, чисельно рівні

Для більшості програмних кодів відмінності проявляються як повсякденні примхи:

  • Цілочисельне додавання є точним, коли збігаються проміжні результати. Поведінка переповнення залежить від мови, а не лише від кодування.
  • Арифметика з плаваючою комою має широкий діапазон, але має кінцеву точність. Округлення може зробити порядок операцій важливим.
  • Перетворення між цілими числами та числами з плаваючою точкою може призвести до втрати інформації. Окрім 2532^{53}, binary64 не може представляти кожне ціле число; Перетворення float-to-integer також залежать від правил мови та діапазону призначення.

Знати, у якій арифметичній системі ви перебуваєте (і її особливості), як правило, важливіше, ніж знати конкретне кодування ваших чисел. Коли розрахунок дає неочікувану відповідь, питання “яка система це впоралася?” це зазвичай найшвидший шлях до відповіді.

Перегляд зображення

Змінюйте масштаб жестом щипка або клавішами + / −. Переміщуйте зображення перетягуванням або стрілками. Esc — закрити.