Небольшой дисклаймер - статья и проект мои. Изначально опубликовал на английском языке, поэтому повесил плашку "перевод" ибо это и есть перевод.

Введение

Как-то мне пришла в голову мысль о том, насколько же быстрее современные процессоры по сравнению с ранними экземплярами. Да, можно размышлять об этом эмпирически - зная тактовую частоту и особенности микроархитектуры (как устроен конвейер, сколько есть ALU, и т.д.), можно прикинуть производительность Intel 4004. Пусть и не в FLOPS'ах, ибо нативная поддержка чисел с плавающей запятой появилась позже. Но это будет весьма грубая прикидка, так как у этого процессора есть несколько интересных черт: разрядность только 4 бита (а не 64, как у большинства современных машин), очень скудный набор инструкций (нет даже AND'a и XOR'a!) и ограничения периферии (в частности памяти не так уж и много).

Поэтому я решил исследовать вопрос на практике. В качестве бенчмарка выбор пал на вычисления числа π. В конце-то концов, даже ENIAC в дремучем 1949 году справился с этой задачей! [2]

Ограничения железа

Обычно у нас есть задача и, исходя из неё, мы выбираем платформу. Но в моём случае ситуация обратная - нужно подобрать алгоритм, который может быть исполнен с учётом ограничений целевого процессора. Ок, какие же это ограничения?

Сам процессор очень прост, и в наборе инструкций присутствуют только несколько базовых операций ALU (сложение/вычитание 4-битных слов), оператор отрицания, циклический сдвиг влево/вправо. И на этом всё... Нет ни умножения, ни деления, ни даже других логических инструкций.

Счетчик команд 12-битный, то есть может адресовать 2^12 байт (4 килобайта). Хотя большинство инструкций и занимают всего 1 байт, некоторые отгрызут целых 2 байта из ROM. Всё это говорит нам о том, что программа не должна быть слишком объемной. Это весьма печально, потому что нам нужно заложить фундамент, реализовав недостающие математические операции самостоятельно (увы не существует алгоритма вычисления π только на сложениях и вычитаниях).

Если говорить про ОЗУ, то каждый RAM чип из семейства MCS-4 (с ними 4004 работает из коробки) состоит из 4х регистров по 20 слов (4-битных, конечно же). Процессор может работать с 8 банками памяти (используя простой 3-к-8 демультиплексор, без него - 4 банка). К счастью, при выборе регистра процессор выдаёт на шину все 4 бита (а не те два, которых было бы достаточно для выбора одного из 4х регистров). И Intel предусмотрительно сделала 4 варианта микросхемы 4002, каждый из которых активен только если при выборе регистра на шине был выставлен соответстующий номер варианта чипа (это сделано через дополнительный пин, а-ля chip enable, подтянутый к старшему разряду и двумя физическими вариантами чипа - 4002-1 и 4002-2, которые отличаются слоем металлизации) [7]. Таким образом, максимальный размер памяти будет 8 банков * 4 чипа * 4 регистра * 20 слов * 4 бита = 10240 бит. Не очень много...

Алгоритм

Существует множество формул вычисления π, но, после изучения того, что у нас есть в распоряжении (особенно количество оперативки), я остановил свой выбор на краниковом (spigot) алгоритме за авторством Stan Wagon и Stanley Rabinowitz [1]. Он достаточно прост для реализации: целочисленное деление и нам не нужна длинная арифметика (в отличии от формул Мэчина). Расскажу немного о том, как же работает этот алгоритм (лучше же конечно почитать первоисточник, но вдруг вам недосуг?).

Используем схему Горнера (4) для вычисления ряда Лейбница для π (1), подвергнутого преобразованию Эйлера (2) и еще одной простой трансформации последовательности (3):

И что же нам даёт схема Горнера? Рассмотрим её применение к десятичным числам (1.234, к примеру):

Присмотревшись, можно заметить сходство с представлением π. Можно сказать, что мы знаем чему равно π - 2.22..2(2)! Но есть нюанс, мы знаем его значение не в десятичной системе счисления, а в смешанной системе счисления с основанием c:

Осталось только сконвертировать его в десятичную форму. Мы будем извлекать цифры по порядку, что весьма несложно для вещественных чисел - выдаём целую часть числа, умножаем дробную часть на 10, выводим целую часть результата, отбрасываем её и повторяем умножение. Следуем этим шагам пока не получим нужную точность. Это универсальный алгоритм, так что он применим и к числам в смешанной системе счисления:

Каждая цифра в системе счисления c (ai) принадлежит алфавиту { 0, 1, ..., (2i + 1) - 1 } = { 0, 1, ..., 2i }. Не совсем очевидно на первый взгляд, но набор символов (алфавит) для цифр в системе счисления p/q и q/p совпадает и равен { 0, 1, ..., max(p-1, q-1) }. [4] Поэтому после умножения на 10, нам нужно вернуть число в корректную форму (каждая цифра должна быть представлена в вышеупомянутом алфавите). Авторы алгоритма называют это форму регулярной.

Мы будем работать с числами в формате (0; a1, a2, a3,... ), где 0 отвечает за целую часть числа. В привычной нам десятичной форме данное число будет в диапазоне [0, 1). Но для конкретно этой смешанной системы счисления диапазон увеличивается до [0, 2). В оригинальной работе есть доказательство, что максимальное число с нулевой целой частью это (0; 2, 4, 6, ...) и оно равно 2 в десятичной форме.

К чему эта информация? А к тому, что получив число (a0; a1, a2, a3,...), мы не можем просто напечатать a0 в качестве очередной цифры π, потому что оставшаяся дробная часть может быть больше единицы и в таком случае добавит дополнительную 1 к десятичной цифре. К примеру, очередная цифра в десятичной форме dk = 5, а на следующем шаге мы можем получить dk+1 = 12, и поэтому нам нужно вернуться в прошлое и вывести на экран 6, а не 5!

Значит нам нужна буферизация вывода, пока мы не будем знать произойдет ли переполнение. Причём недостаточно буферезировать только одну цифру, потому что нам может встретиться каскадное переполнение и нам нужно будет вместо ...2999x... вывести ...3000y...

Реализация на языке высокого уровня

В изначальной статье авторы используют Pascal, но для меня он не так привычен, поэтому я переписал алгоритм на JavaScript, дабы иметь какой-то референс перед написанием ассемблерного кода.

Если написать в лоб, то получим что-то такое:

const N = 1000;
const LEN = Math.floor((10 * N) / 3) + 1;

const a = Array(LEN);
let previousDigit = 2;
let nineCount = 0;

for (let i = 0; i < LEN; i++) {
  a[i] = 2;
}

let printed = 0;
while (printed < N) {
  // multiply each digit by 10
  for (let i = 1; i < LEN; i++) {
    a[i] = a[i] * 10;
  }

  // normalize representation
  //   each digit should be in range 0..2i, so carry extra rank to higher digit
  let carry = 0;
  for (let i = LEN - 1; i > 0; i--) {
    const denominator = i, numerator = 2 * i + 1;
    const x = a[i] + carry;
    a[i] = x % numerator
    carry = Math.floor(x / numerator) * denominator;
  }

  // latest carry would be integer part of current number 
  //   and sequental digit of Pi
  const digitFromCarry = Math.floor(carry / 10);
  const nextDigit = carry % 10;

  // if current digit is 9, then we can't decide if we would have cascade carry
  if (nextDigit === 9) {
    nineCount++;
    continue;
  }

  // print previous digit, because now we knows if current digit is more than 10
  const currentDigit = previousDigit + digitFromCarry;
  process.stdout.write(currentDigit.toString());
  printed++;

  // if previous digit is followed by 9s, then print them 
  //   or 0s, if we have cascade carry
  for (let i = 0; i < nineCount; i++) {
    process.stdout.write((digitFromCarry === 0 ? 9 : 0).toString());
    printed++;
  }
  nineCount = 0;

  previousDigit = nextDigit;
}

Уже на этом этапе мы можем внести улучшения. Оптимизировать вывод цифр необязательно (занимает меньшую часть времени), а вот подумать над производительностью цикла нормализации можно (циклы обычно очевидная цель для оптимизаций):

  1. Можно объединить умножение и нормализацию в одном цикле, а не делать это в два шага.

  2. Вместо вычисления числителя на каждой итерации мы можем загнать его в качестве счетчика цикла.

  3. В JavaScript'e нет оператора целочисленного деления, который одновременно выдаёт частное и остаток от деления, но мы можем создать такой оператор в реализации кода под Intel 4004.

Применив эти оптимизации, получим примерно такой цикл:

  for (let i = LEN - 1, numerator = (2 * LEN - 1); i > 0; i--, numerator -= 2) {
    const x = a[i] * 10 + carry;
    a[i] = x % numerator
    carry = Math.floor(x / numerator) * i;
  }

Сколько же цифр мы сможем вычислить в теории?

Возможно вы заметили, что нам нужно всегда хранить число в памяти. Но памяти не так много. Доказано, что если мы хотим получить N десятичных цифр π, то нам нужно число в смешанной системе c, которое имеет как минимум (10 * N / 3) + 1 разрядов. Зная сколько у нас памяти (10240 бит), мы можем вычислить на сколько цифр её хватит:

Решив это неравенство, получаем N = 279, и разрядность в 11 бит на элемент массива.

Но я очень не хотел вводить битовую арифметику и хранить цифры не по границам 4-битных слов. Да, мы можем иметь, скажем, 14-битные цифры, но это будет больно и потребует много кода, так как в Intel 4004 нет поддержки многих битовых операций. Есть еще вариант с плавающей разрядностью - первые цифры числа в системе счисления c используют куда меньший набор символов из общего алфавита. Но это тоже звучит весьма сложно для кодинга.

Поэтому выбираем между 8-битными цифрами (сможем вычислить только 38 цифр) и 12-битными (что даст нам первые 255 цифр π). Думаю, понятно какой вариант предпочтительнее. Кроме того, мы получим свободные 40 бит под переменные.

Реализация алгоритма на ассемблере для Intel 4004

Я предполагал, что отладка кода на реальном железе будет весьма непростым занятием, поэтому для начала я написал свой эмулятор. Да, есть несколько достойных проектов. Но я привык к удобным IDE с подсветкой кода, брейкпойнтами и горячими клавишами. Кроме того, я планировать написать не такую уж и короткую программу. Разработал ассемблер (не язык, а непосредственно программа, которая преобразовывает код в байты), а затем и эмулятор, запускаемый в браузере.

Я уже касался набора команд для intel 4004, но поговорим о нём немного подробнее. Всего существует 45 инструкций, треть из которых относится к ОЗУ и вводу/выводу. Не планирую описывать все нюансы программирования систем на основе MCS-4, ибо есть хороший мануал на этот счет (даже с релевантными примерами кода!) [8], но упомяну несколько интересных моментов, которые сказывались на процессе написания кода:

  • Процессор предоставляет доступ к 16 регистрам общего назначения (4-битным). Обращаться из кода можно к ним поодиночке, а можно и попарно как к квази-восьмибитному регистру (но только для ряда инструкций, вся арифметика по-прежнему 4-битная). В набор регистров также включен специальный регистр-аккумулятор, только с которым работают очень многие команды. Значение одного регистра не превышает 15, а почти все наши переменные будут больше. Поэтому нужно заранее спроектировать распределение переменных по регистрам. Даже дополнительные 40 бит в памяти не позволяют разгуляться.

  • Если говорим о регистрах, то нет инструкции загрузки заданного значения в отдельный регистр. Однако можно загрузить значение в регистровую пару (подпортив соседний регистр), либо обменяться содержимым с аккумулятором (для которого уже есть инструкция загрузки заданного числа).

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

  • Среди флагов у нас присутствует флаг переноса, который устанавливается в 1 после операции сложения если результат превышает 15. Но вот что очень неинтуитивно, так это то, что флаг будет установлен в 0 при отрицательном переносе (когда вычитаем из меньшего числа большее). Приходилось постоянно корректировать его после операций вычитания. Этот флаг устанавливается только при операциях с регистром-аккумулятором. Если произойдет переполнение при инкременте обычного регистра, то флаг не поменяет своего значения.

  • Некоторые прыжковые инструкции работают только в 8-битном диапазоне и это не позволяет делать прыжки между страницами ПЗУ (старшие 4 бита адреса для прыжка должны быть неизменными). Так что нужно следить еще и за этим - не получится сделать условный прыжок с адреса 0x123 на адрес 0x245.

  • Но это еще не всё! Из-за особенностей процессора, если ты делаешь короткий прыжок с последних байт страницы ПЗУ (адреса вида 0x*FE / 0x*FF), то счетчик команд успеет инкрементироваться до того, как прыжок будет сделан и в итоге мы прыгнем на следующую страницу. К примеру если с 0x1FF хотим прыгнуть на 0x123 (что в теории норм), то в итоге попадём на 0x223. И такое мне встречалось в реальном коде, увы...

  • Доступ к ОЗУ состоит из трех этапов - выбор банка ОЗУ, выбор чипа/регистра/слова и только затем выполнение необходимого действия: чтение, запись, доступ к порту ввода-вывода.

  • Уже упоминал, но хочу еще раз сакцентировать - из логических операций у нас только отрицание и циклический сдвиг.

Пример простой программы, которая пробегается по ОЗУ и записывает в неё какие-то данные:

  FIM r0, 0x80
loop_bank:
  // select bank
  LD rr1
  DCL
  // iterate through reg number
  FIM r1, 0x00
loop_reg:
  LDM 0
  XCH rr3
  SRC r1
  LD rr1
  WRM // write rr1 to [#rr1, #rr2, M0]
  WR0 // write rr1 to [#rr1, #rr2, S0]
  INC rr3
  SRC r1
  LD rr2
  WRM // write rr2 to [#rr1, #rr2, M1]
  WR1 // write rr2 to [#rr1, #rr2, S1]
  ISZ rr2, loop_reg
  // end of loop_reg
  INC rr1
  ISZ rr0, loop_bank

Конечно же, я не буду сюда постить весь ассемблерный листинг на 1500 строк кода. Если вам интересно, то можете взглянуть на него здесь. Разве что дам небольшие пояснения по блокам кода.

Для начала я реализовал необходимую арифметику над 4-битными словами - умножение, деление, сдвиговые операции. Затем мне нужно была многоразрядное деление (до 5 разрядов по 4 бита каждый). Кстати, этот функционал занимает весьма приличную часть всего кода. Реализация основана на широкоизвестных алгоритмах [5][6], поэтому не буду углубляться в детали.

Изначально я соптимизировал код, храня числитель и знаменатель в отдельных переменных цикла, но увы после подсчетов, я выяснил, что регистров/памяти не хватает, поэтому пришлось пересчитывать знаменатель на каждой итерации.

Как ни странно, еще один большой кусок кода отвечает за операции с памятью. В частности, нам нужно конвертировать индекс элемента в массиве (разряд) в линейный адрес (делается весьма просто - address = (denominator - 1) * 3), а затем уже в физический:

bank index      = address / 320
register index  = (address % 320) / 20
character index = ((address % 320) % 20)

Достоен внимания еще тот момент, что у нас 2 набора инструкций для работы с ОЗУ. Первый набор взаимодействует с 16 словами ОЗУ регистра (RDM/WRM), но нужно предварительно совершать процедуру выбора этого слова, что занимает лишние байты и такты. Второй набор даёт доступ к отдельным 4 словам (RDx / WRx, где x от 0 до 3) - это позволяет единожды выбрать регистр, и работать с отдельными четырьмя "ячейками", без необходимости выбирать их отдельно.

В принципе, код достаточно линеен и понятен (если вы знакомы с ассемблером Intel 4004, конечно же).

Запускаем программу на реальном железе

Окей, у нас есть эмуляция, но я всё же хотел посмотреть реальную производительность процессора. Значит мне нужна настоящая система на процессоре Intel 4004. У меня не было цели собирать аутентичную MCS-4 систему, нужен был только процессор. Поэтому я реализовал симулятор ПЗУ (intel 4001) и ОЗУ (intel 4002) на микроконтроллере семейства stm32. Даже не самые производительные варианты укладывались в необходимые тайминги. Спасибо Frank Buß за его дизайн схемотехники. Разве что он брал PIC16 микроконтроллер. Но я использовал его схему почти без изменений:

Прошивка не представляет из себя ничего интересного - генерируется 2-фазный тактовый сигнал (использовал два встроенных таймера в режиме master-slave для генерации ШИМ сигналов), затем слушаем UART и по команде грузим образ ПЗУ в нашу память и стартуем 4004 процессор. Единственный узкий момент был в том, чтобы уместить всю логику в 1000ns окно.

Потратив некоторые время на отладку, я всё-таки добился стабильности:

Итоги

Программа успешно запустилась и выдала корректные цифры π. Я считаю, что это успех. Сколько же ушло на это процессорного времени?

3 часа, 31 минута и 13 секунд. Дополнительно stm32 считал такты и результат совпал с замерами на ПК (0x3AFAB080 = 989507712 cycles * 8 * 1600ns = 12665s)

Просто для сравнения, на моём старом Xeon'e вычисление 25 миллионов цифр числа π заняло куда меньше секунды. Я даже не буду пытаться сравнивать эти значения. В любом случае было интересно поработать с самым первым процессором от Intel (которому исполнилось уже 50 лет!).

References

[1]: Stanley Rabinowitz and Stan Wagon, "A Spigot Algorithm for the Digits of π"
[2]: Brian J. Shelburne, "The ENIAC's 1949 Determination of π"
[3]: Intel, "Intel Data Catalog 1975"
[4]: Shigeki Akiyama, Christiane Frougny, Jacques Sakarovitch, "On the Representation of Numbers in a Rational Base"
[5]: Donald E. Knuth, "Art of Computer Programming, Volume 2: Seminumerical Algorithms", 4.3. Multiple Precision Arithmetic
[6]: Henry S. Warren, "Hacker's Delight"
[7]: Intel MCS-4 Data Sheet
[8]: Intel MCS-4 Users Manual