- Объяснять устройство компьютера фон Неймана и цикл «выборка–декодирование–исполнение»
- Вычислять процессорное время и среднее время доступа к памяти
- Записывать целые числа в дополнительном коде и распознавать переполнение
- Кодировать число в формате IEEE 754 и объяснять ошибки округления
Процессор твоего телефона с частотой 3 ГГц выполняет 3 миллиарда тактов в секунду, но если написать в Python 0.1 + 0.2 == 0.3, ответ будет False. Оба факта вытекают из устройства компьютера: процессор выбирает и выполняет команды одну за другой, а числа хранит в конечном числе битов. В этом уроке мы изучим те части «железа», которые сильнее всего влияют на программиста.
Архитектура фон Неймана и цикл команд
Программа и данные хранятся в одной и той же памяти; процессор (арифметико-логическое устройство, устройство управления и регистры) последовательно выбирает команды из памяти и выполняет их; всё соединено шиной. Чтобы сменить программу, достаточно изменить содержимое памяти, а не провода.
| Регистр | Назначение |
|---|---|
| PC — счётчик команд | адрес следующей команды |
| IR — регистр команды | выполняемая сейчас команда |
| MAR / MDR | адрес памяти и читаемые/записываемые данные |
| ACC и регистры общего назначения | промежуточные результаты вычислений |
| Флаги | признаки нуля, знака, переноса, переполнения |
- 1Выборка (fetch)
Адрес из PC попадает в MAR, команда читается из памяти в IR, PC переходит к следующей команде.
- 2Декодирование (decode)
Устройство управления определяет код операции и операнды.
- 3Исполнение (execute)
АЛУ вычисляет или данные читаются/пишутся в память; команда перехода меняет PC. Затем цикл повторяется.
memory = [('LOAD', 5), ('ADD', 6), ('STORE', 7), ('PRINT', 7), ('HALT', 0), 20, 22, 0]
pc, acc, executed = 0, 0, 0
while True:
op, addr = memory[pc]
pc += 1
executed += 1
if op == 'LOAD':
acc = memory[addr]
elif op == 'ADD':
acc += memory[addr]
elif op == 'STORE':
memory[addr] = acc
elif op == 'PRINT':
print('memory[7] =', memory[addr])
elif op == 'HALT':
break
print('instructions executed:', executed, '| PC =', pc, '| ACC =', acc)▸ Ожидаемый результат
memory[7] = 42 instructions executed: 5 | PC = 5 | ACC = 42
memory[pc]), увеличение PC, декодирование (if op == …), исполнение.- tпроцессорное время программы, с
- Nчисло выполненных команд
- CPIсреднее число тактов на команду
- fтактовая частота, Гц
«Основное уравнение» производительности процессора: чтобы ускориться, нужно меньше команд (лучший алгоритм и компилятор), меньший CPI (конвейер, кэш) или более высокая частота.
Программа выполняет 2 · 10⁹ команд, CPI = 1,5, частота 3 ГГц. Каково процессорное время? Что будет, если компилятор сократит число команд на 20 %, но CPI вырастет до 1,6?
Показать решениеСкрыть решение
Новый вариант: N = 1,6 · 10⁹, t = 1,6 · 10⁹ · 1,6 / (3 · 10⁹) = 2,56 / 3 ≈ 0,853 с.
Хотя каждая команда требует больше тактов, общее время уменьшилось на ≈ 15 % — нельзя судить по одному показателю.
Иерархия памяти и кэш
Процессор примерно в сто раз быстрее оперативной памяти (RAM) — это «бутылочное горлышко» фон Неймана. Решение — иерархия памяти: маленькие и быстрые уровни кэша рядом с процессором и большие, медленные виды памяти дальше. Кэш опирается на локальность: недавно использованные данные скоро понадобятся снова (временная локальность), как и соседние адреса (пространственная локальность). Поэтому память подгружается строками кэша по 64 байта.
| Уровень | Типичный объём | Примерная задержка |
|---|---|---|
| Регистры | несколько сотен байт | ≈ 1 такт |
| Кэш L1 | 32–64 КБ на ядро | ≈ 1 ns |
| Кэш L2 | 0,25–2 МБ на ядро | ≈ 3–5 ns |
| Кэш L3 | общий, десятки МБ | ≈ 10–20 ns |
| Оперативная память (RAM) | гигабайты | ≈ 60–100 ns |
| SSD | сотни ГБ – ТБ | ≈ 20–100 мкс |
- AMATсреднее время доступа к памяти
- thitвремя доступа при попадании в кэш
- mдоля промахов
- tmissштраф за промах (обращение к следующему уровню)
L1: 1 нс, доля промахов 5 %. L2: 4 нс, промахивается в 20 % дошедших до него запросов. RAM: 100 нс. Найди AMAT. А если бы L2 не было?
Показать решениеСкрыть решение
AMAT = 1 + 0,05 · 24 = 1 + 1,2 = 2,2 нс.
Без L2: AMAT = 1 + 0,05 · 100 = 6 нс — примерно в 2,7 раза медленнее.
Вывод: «дружественный к кэшу» код (последовательный обход массива) гораздо быстрее разбросанных обращений (связный список).
Целые числа: дополнительный код
В n-битном дополнительном коде старший бит имеет отрицательный вес: −2ⁿ⁻¹. Поэтому отрицательные числа начинаются с 1, а отдельный «вычитатель» не нужен: АЛУ вычисляет a − b как a + (−b). Само сложение строится из логических элементов: в полусумматоре бит суммы — a XOR b, а перенос — a AND b.
- NOT xинверсия всех битов x
- nчисло битов (8 бит: −128 … 127)
а) Запиши −45 в 8-битном дополнительном коде. б) Чему будет равно 100 + 50 в 8-битной знаковой арифметике?
Показать решениеСкрыть решение
Проверка: −128 + 64 + 16 + 2 + 1 = −45 ✓ (как беззнаковое это 211 = 256 − 45).
б) 100 = 01100100, 50 = 00110010, сумма = 10010110.
Старший бит стал 1 → −128 + 16 + 4 + 2 = −106. Сумма двух положительных чисел оказалась отрицательной — это переполнение: 150 > 127. Результат «заворачивается»: 150 − 256 = −106.
Числа с плавающей запятой: IEEE 754
- sбит знака (1 бит)
- eсмещённый порядок (8 бит, смещение 127)
- mдробная часть мантиссы (23 бита; ведущая 1 не хранится)
32-битный формат (single). 64-битный double: 1 + 11 + 52 бита, смещение 1023, точность около 15–16 десятичных цифр. float в Python — это double.
Запиши −6,25 в формате IEEE 754 single (биты и шестнадцатеричный код).
Показать решениеСкрыть решение
6,25 = 6 + 0,25 = 110₂ + 0,01₂ = 110,01₂ = 1,1001₂ · 2².
Порядок: e = 2 + 127 = 129 = 10000001₂.
Мантисса (без ведущей 1): 1001 и 19 нулей.
Биты: 1 | 10000001 | 10010000000000000000000
Группы по 4: 1100 0000 1100 1000 0000 … → C0C80000₁₆.
import struct, math
def to_bits(x, bits=8):
return format(x % 2 ** bits, '0' + str(bits) + 'b')
def from_bits(s):
v = int(s, 2)
return v - 2 ** len(s) if s[0] == '1' else v
print(to_bits(45), to_bits(-45), from_bits('11010011'))
print('100 + 50 in 8 bits:', from_bits(to_bits(100 + 50)))
raw = struct.pack('>f', -6.25).hex()
print('-6.25 as float32:', raw, format(int(raw, 16), '032b'))
print(0.1 + 0.2, 0.1 + 0.2 == 0.3, math.isclose(0.1 + 0.2, 0.3))
print(struct.unpack('>f', struct.pack('>f', 0.1))[0])▸ Ожидаемый результат
00101101 11010011 -45 100 + 50 in 8 bits: -106 -6.25 as float32: c0c80000 11000000110010000000000000000000 0.30000000000000004 False True 0.10000000149011612
x % 2 ** bits «наматывает» число на n-битный круг — это и есть дополнительный код. struct показывает настоящие 32 бита: тот же C0C80000, что мы получили вручную. 0,1 — бесконечная двоичная дробь (0,000110011…₂), поэтому во float32 это 0,10000000149…, а в double 0,1 + 0,2 = 0,30000000000000004.Главное
- В компьютере фон Неймана программа и данные в одной памяти; процессор повторяет цикл выборка–декодирование–исполнение.
- Процессорное время t = N · CPI / f; производительность зависит от всех трёх множителей.
- Кэш опирается на локальность; AMAT = thit + m · tmiss.
- Дополнительный код: −x = NOT x + 1, n бит покрывают −2ⁿ⁻¹ … 2ⁿ⁻¹ − 1; выход за диапазон — переполнение.
- IEEE 754: знак, смещённый порядок, мантисса; числа вроде 0,1 неточны, поэтому не сравнивай float через ==.
Проверь себя
Вопросов: 10. Каждый правильный ответ приносит XP.