01/06/2020

Маленькая ассемблерная вставка в мой блог (1): оптимизация выставления переменных состояния

Наблюдая за алгоритмами, по которым работают механические устройства, я начал задумываться — а что, если логика программы сама по себе избыточна? Например, принтер всегда делает какие-то движения — ёрзает кареткой туда-сюда, крутит барабан взад-вперёд и, в итоге, приводит себя в положение, необходимое для работы, игнорируя своё изначальное состояние и, что самое главное — не завися от этого состояния. Это конечно заставляет больше ждать пользователя, но удешевляет устройство так как снижает количество элементов, обеспечивающих обратную связь (или вовсе исключает их) — датчики позиций, счётчики оборотов и положений механизмов. А так же, я полагаю, это упрощает алгоритм и повышает его надёжность — в более высокой степени гарантирует приведение системы к требуемому состоянию за счёт исключения сложных логических операций (вычисление состояния/положения каретки, вычисление расстояния, которое, ей надо пройти). Я обратил внимание на то, что можно делать сначала какие-то безусловные действия (настройки) и лишь затем, модифицировать состояние системы, для приведения её в требуемое и отличное от изначального состояние по условиям. То есть можно снизить количество логики, повысив количество безусловных действий. Но кто-то, возможно, скажет что это приведёт к избыточным действиям (тем более, что я только что описал подобные, избыточные действия механической системы). Это нужно проработать и проверить.
Перейдём в плоскость ИТ/ВТ и конкретно разработки ПО. Представим себе пример — нам нужно выставить какой-то флаг в зависимости от условия — флаг наличия или количества байт в буфере I2C, одно из состояний элемента GUI. Часто люди думают (и пишут) конструкции типа:
if (condition) then
    flag = value_1;
else
    flag = value_2;
или даже:
if (condition) then
    flag = value_1;
if (!condition) then
    flag = value_2;
Я всегда хотел проверить, будет ли вариант:
flag = value_2;
if (condition) then
    flag = value_1;
эффективнее для вычислительной машины. Я предполагал, что такая конструкция должна компилироваться в код, где меньше команд JMP (и сходных — JG, JE, JNE). Чтобы проверить это я написал три варианта решения этой задачи и назвал их «if-then-else», «ternary» и «if-then». if-then-else — самый понятный и, как мне кажется, первый приходящий в голову вариант. С ternary всё понятно — это выставка флага по тернарному оператору. if-then — я назвал вариант, придуманный мной (скорее — который я хочу проверить), где сначала выставляется флаг, затем происходит проверка условия, необходимого для изменения состояния флага и изменение флага если условие истинно, соответственно. Вот эти блоки кода (оформлены в отдельные программы):
// if-then-else:
int main ()
{
    int n = 1;
    if (n < 0xC0FFEE)
        n = 2;
    else
        n = 3;
    return 4;
}
// ternary:
int main ()
{
    int n = 1;
    n = (n < 0xC0FFEE) ? 2 : 3;
    return 4;
}
// if-then:
int main ()
{
    int n = 1;
    n = 2;
    if (n < 0xC0FFEE)
        n = 3;
    return 4;
}
В этих программах я использовал числа 1, 2, 3 и возвращаемое значение 4, чтобы по ним, в последствии, ориентироваться в ассемблерном коде.
Теперь посмотрим как выглядит этот код на ассемблере:
1.file "if-then-else.c"
2.text
3.globl main
4.type main, @function
5main:
6.LFB0:
7.cfi_startproc
8movl $1, -4(%rsp)
9cmpl $12648429, -4(%rsp)
10jg .L2
11movl $2, -4(%rsp)
12jmp .L3
13.L2:
14movl $3, -4(%rsp)
15.L3:
16movl $4, %eax
17ret
18.cfi_endproc
19.LFE0:
20.size main, .-main
21.ident "GCC: (GNU) 9.3.0"
22.section .note.GNU-stack,"",@progbits
1.file "ternary.c"
2.text
3.globl main
4.type main, @function
5main:
6.LFB0:
7.cfi_startproc
8movl $1, -4(%rsp)
9cmpl $12648429, -4(%rsp)
10jg .L2
11movl $2, %eax
12jmp .L3
13.L2:
14movl $3, %eax
15.L3:
16movl %eax, -4(%rsp)
17movl $4, %eax
18ret
19.cfi_endproc
20.LFE0:
21.size main, .-main
22.ident "GCC: (GNU) 9.3.0"
23.section .note.GNU-stack,"",@progbits
1.file "if-then.c"
2.text
3.globl main
4.type main, @function
5main:
6.LFB0:
7.cfi_startproc
8movl $1, -4(%rsp)
9movl $2, -4(%rsp)
10cmpl $12648429, -4(%rsp)
11jg .L2
12movl $3, -4(%rsp)
13.L2:
14movl $4, %eax
15ret
16.cfi_endproc
17.LFE0:
18.size main, .-main
19.ident "GCC: (GNU) 9.3.0"
20.section .note.GNU-stack,"",@progbits
Части кода, что одинаковы, затенены серым цветом, а те, что нас нтересуют — выделены в центре. Таким образом мы фокусируемся только на том, что нас интересует, отсекая общие для всех программ участки кода.
Что мы видим:
        movl $1, -4(%rsp)
Здесь мы иницилизируем переменную единицей (именно для того чтобы понять, где начинается интересующий нас код, я это и делаю).
        movl $4, %eax
        ret
Это наш return 4; (именно для того чтобы понять, где заканчивается интересующий нас код, я и возвращаю четвёрку).
Можно видеть, что всё происходящее вполне прозрачно и подтверждает теорию, выдвинутую мною. Теперь мы можем отранжировать методы.
if-then показал себя самым компактным, не только с точки зрения операций, но ещё и тем, что содержит на одну метку меньше, а метки так же хранятся в ELF-файле, что, в нашем случае говорит об экономии места. Стоит отметить, что место экономится гораздо меньше, чем скорость — метка в файл записывается один раз, грузится тоже один раз, а вот исполняться каждый такой участок кода в программе может неисчислимое количество раз.
На втором месте if-then-else. Вполне логично больше на одну метку и на одну операцию перехода.
А вот ternary лично меня удивил. Это самый худший вариант, как с точки зрения размера, так и с точки зрения скорости (количества операций). В этой ситуации GCC сгенерировал код, который, кроме всех операций, что выполняет в варианте if-then-else, зачем-то работает сначала через регистр EAX, а затем перекладывает значение в переменную.
Вывод: да, вариант if-then эффективнее с точки зрения выполнения на машине. Хотя такая конструкция может несколько хуже восприниматься человеком — «Как это, сначала что-то приравнивается, а потом проверяется условие, и если оно не выполняется, то флаг вообще остаётся без внимания» — как будто кто-то забыл дописать строчку. Но если люди часто используют тернарную форму записи, которая вообще «не для людей» и считают себя «сеньорами», то я думаю мы можем писать в стиле if-then и тоже вполне обосновано считать себя сеньорами.
Здесь добавлю, что мы можем пользоваться этим методом не только для выставления флагов типа 1/0 или типа Boolean — метод if-then можно использовать и для выставления иных изначальных значений — цвет кнопки зелёный, а если имеется состояние ошибки, то выставлять его в красный; количество байт в буфере — ноль, а если есть что-то на входе, то выставляем этот счётчик в нужное количество и т.д..
Так же методом if-then можно экономить (хоть и не так много в процентном соотношении) и на более сложных конструкциях ветвления: цвет кнопки зелёный, если состояние ошибки — выставить в красный, если состояние промежуточное — выставить в оранжевый. Это сэкономит не 50%, как в вышеописанных ситуациях, а количество состояний за вычетом одного перехода и одной метки. Используя метод if-then, стоит вначале выставлять переменную в максимально часто возникающее состояние (если это можно продумать) — в таком случае часто будет происходить одна операция типа MOV, за ней одна или более операций типа JMP и минимальное количество случаев с повторным MOV, так как вы уже выставили флаг в максимально часто возникающее состояние.
В следующих статьях можно будет рассмотреть что получается из иных стандартных языковых конструкциях и как можно на них срезать углы.