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

Приведённый ниже код я встретил в вайб-код проекте VibeTensor. Я исследую подобные проекты в качестве натуралиста (1, 2). Мне интересен генезис новых видов дефектов и недостатков в коде. Это позволит лучше понимать и описывать современные проблемы качества кода в эпоху генеративного AI (GenAI).
Одно из наблюдений – генерированный код более "пухлый", что затрудняет его восприятие человеком и оптимизацию компиляторами. Следующий фрагмент кода как раз это хорошо демонстрирует.
static TensorImpl make_contiguous_tensor(const std::vector<int64_t>& sizes) {
const std::size_t nd = sizes.size();
std::vector<int64_t> strides(nd, 0);
int64_t acc = 1;
for (std::ptrdiff_t i = static_cast<std::ptrdiff_t>(nd) - 1; i >= 0; --i) {
strides[static_cast<std::size_t>(i)] = acc;
const auto sz = sizes[static_cast<std::size_t>(i)];
acc *= (sz == 0 ? 1 : sz);
}
int64_t ne = 1;
bool any_zero = false;
for (auto s : sizes) {
if (s == 0) {
any_zero = true;
break;
}
ne *= s;
}
if (any_zero) {
ne = 0;
}
....
}
С одной стороны, размер и скорость этого кода некритичны, так как он относится к тестам.
Однако этот код размножен по 9 файлам. Оставим за скобками, что такого по-хорошему вообще быть не должно. Но раз код размножается почкованием, желательно чтобы он был бы тогда по возможности компактным.
А ещё важно, что генерируется С++ код, и он обязан быть оптимальным. Само назначение языка – высокоэффективные приложения. В рассматриваемом месте – это просто медленный тест. Но в другом месте что-то подобное приведёт к существенному замедлению приложения. Или скажется кумулятивный эффект множества неудачных фрагментов сгенерированного кода.
Медленный С++ код – это противоестественно. Поэтому по-прежнему полезно развивать свою экспертность в понимании, удачным ли получился код и как его можно улучшить, сократить, оптимизировать. Или сгенерировать снова, используя уточнения.
В общем давайте потренируемся и проведём рефакторинг. Начнём с этого фрагмента:
int64_t ne = 1;
bool any_zero = false;
for (auto s : sizes) {
if (s == 0) {
any_zero = true;
break;
}
ne *= s;
}
if (any_zero) {
ne = 0;
}
Здесь перемножаются все элементы массива. Если встретится 0, то цикл прервётся, чтобы зря не обрабатывать оставшиеся элементы массива. Всё равно ведь ноль получится.
Чтобы обнулить переменную, где хранится произведение, используется флаг any_zero. Этот подход избыточен, можно проще.
int64_t ne = 1;
for (auto s : sizes) {
if (s == 0) {
ne = 0;
break;
}
ne *= s;
}
Когда встретится 0, обнулятся ne и цикл завершится. Можно продолжить упрощение. Современные процессоры быстро выполняют операции умножения. Поэтому можно в начале перемножить, а потом уже проверить.
int64_t ne = 1;
for (auto s : sizes) {
ne *= s;
if (s == 0) {
break;
}
}
Функциональность кода не изменилась, но он стал короче и, на мой взгляд, даже понятнее.
Если заранее знать, что всегда обрабатывается только несколько элементов или что тут точно не требуется какая-то скорость (вызывается в редкой ситуации), то код можно упростить до:
int64_t ne = 1;
for (auto s : sizes) {
ne *= s;
}
Мы не будем думать про это, так как впоследствии рассмотренный цикл вообще будет "поглощён" другим.
Теперь вернёмся к началу функции.
static TensorImpl make_contiguous_tensor(const std::vector<int64_t>& sizes) {
const std::size_t nd = sizes.size();
std::vector<int64_t> strides(nd, 0);
int64_t acc = 1;
for (std::ptrdiff_t i = static_cast<std::ptrdiff_t>(nd) - 1; i >= 0; --i) {
strides[static_cast<std::size_t>(i)] = acc;
const auto sz = sizes[static_cast<std::size_t>(i)];
acc *= (sz == 0 ? 1 : sz);
}
Из-за static_cast код выглядит тяжеловесным. Первое приведение типа нужно, чтобы какие-то компиляторы/статические анализаторы не ругались на странные арифметические игры.
std::ptrdiff_t i = static_cast<std::ptrdiff_t>(nd) - 1;
Рассмотрим, что будет, если убрать static_cast, а входной массив окажется пустым:
nd = 0;SIZE_MAX, т.е. очень большое положительное беззнаковое число.SIZE_MAX типа size_t неявно преобразуется в тип ptrdiff_t и записывается в переменную i. Получается, что i = -1, как и было задумано. Но вот тут как раз могут быть выданы предупреждения. Ведь мы инициализируем ptrdiff_t числом, которое больше диапазона максимально вмещаемого числа. Поведение в такой ситуации до C++20 определяется реализацией – implementation defined behavior. После C++20 поведение определено.Итого: первый static_cast лучше оставить на месте. Про остальные такого сказать нельзя. В них нет никакого смысла.
Оператор [] в классе vector принимает аргумент типа size_type (этот тип является синонимом size_t). Значение переменной i автоматически будет преобразовано в size_t, и в этом нет чего-то странного, опасного или подозрительного. Явное приведение типов только загромождает код, и от него лучше избавиться.
for (std::ptrdiff_t i = static_cast<std::ptrdiff_t>(nd) - 1; i >= 0; --i) {
strides[i] = acc;
const auto sz = sizes[i];
acc *= (sz == 0 ? 1 : sz);
}
Первый шаг сделан. Можно теперь всё-таки упростить длинную строку с циклом? Давайте подумаем. Хочется написать как-то так:
for (auto sz : std::ranges::views::reverse(sizes)) {
strides[???? i ????] = acc;
acc *= (sz == 0 ? 1 : sz);
}
Всё равно требуется переменная i для обхода массива strides, начиная с конца. Так что совсем упростить код и избавиться от i не получается. Поэтому сделаем так:
int64_t acc = 1;
size_t i = nd;
for (auto sz : std::ranges::views::reverse(sizes)) {
strides[--i] = acc;
acc *= (sz == 0 ? 1 : sz);
}
Если честно, мне не нравится, что из-за использования --i код стал сложнее. Теперь требуется вникнуть, почему переменная в начале уменьшается, а уже затем используется для обращения к элементу массива.
С другой стороны, кода стало меньше и его можно быстрее просмотреть глазами. Так что, наверное, когнитивная сложность кода осталась в итоге такой же. Т.е. понимать код стало не проще, но и не сложнее. В любом случае код стал покороче, так что ok.
Взглянем целиком на вариант кода, который получился.
static TensorImpl make_contiguous_tensor(const std::vector<int64_t>& sizes) {
const std::size_t nd = sizes.size();
std::vector<int64_t> strides(nd, 0);
int64_t acc = 1;
size_t i = nd;
for (auto sz : std::ranges::views::reverse(sizes)) {
strides[--i] = acc;
acc *= (sz == 0 ? 1 : sz);
}
int64_t ne = 1;
for (auto s : sizes) {
ne *= s;
if (s == 0) {
break;
}
}
....
}
Теперь, когда кода меньше, становится очевидным, что второй цикл избыточен. В первом цикле мы перебираем все элементы. Так почему бы их сразу не перемножить?
static TensorImpl make_contiguous_tensor(const std::vector<int64_t>& sizes) {
const std::size_t nd = sizes.size();
std::vector<int64_t> strides(nd, 0);
int64_t acc = 1;
size_t i = nd;
int64_t ne = 1;
for (auto sz : std::ranges::views::reverse(sizes)) {
strides[--i] = acc;
acc *= (sz == 0 ? 1 : sz);
ne *= sz;
}
....
}
Красота. Мы перемножаем все элементы, несмотря на то, что один из них может оказаться нулевым? Нестрашно. Микропроцессоры сейчас быстро умножают. Можно потерять больше на повторном доступе ко всем элементам во втором цикле.
Что ещё осталось? Не требуется изначально обнулять контейнер strides нулями. Всё равно все его элементы будут перезаписаны.
std::vector<int64_t> strides(nd, 0); // надо убрать второй аргумент
В принципе, мы закончили. Но можно сделать ещё одно косметическое изменение, избавившись от переменной nd. Она ни здесь, ни в последующем коде не нужна. Итоговый код:
static TensorImpl make_contiguous_tensor(const std::vector<int64_t>& sizes) {
auto q = sizes.size();
std::vector<int64_t> strides(q);
int64_t acc = 1;
int64_t ne = 1;
for (auto sz : std::ranges::views::reverse(sizes)) {
strides[--q] = acc;
acc *= (sz == 0 ? 1 : sz);
ne *= sz;
}
....
}
Код сократился в два раза: с 20 до 9 строк!
Хорошо, текст функции стал короче, но скажется ли это положительно на производительности? Да. Посмотрим на основной фрагмент ассемблерного кода для изначального варианта функции. Используется Clang с ключом -O2.
.LBB1_7:
mov rax, rsi
mov qword ptr [rbx + 8*r12 - 16], rsi
mov rsi, qword ptr [r13 + 8*r12 - 16]
cmp rsi, 1
adc rsi, 0
imul rsi, rax
dec r12
cmp r12, 1
ja .LBB1_7
mov r12d, 1
cmp rbp, r13
je .LBB1_9
.LBB1_4:
mov rax, qword ptr [r13]
test rax, rax
je .LBB1_5
imul r12, rax
add r13, 8
cmp r13, rbp
jne .LBB1_4
jmp .LBB1_9
Достаточно многословный ассемблерный код с двумя циклами. Теперь посмотрим, что сгенерировано для последнего сокращённого кода.
.LBB1_10:
mov rcx, rsi
mov rdx, qword ptr [rbp - 8]
add rbp, -8
mov qword ptr [rax], rsi
cmp rdx, 1
mov rsi, rdx
adc rsi, 0
imul rsi, rcx
imul r12, rdx
add rax, -8
cmp rbp, r13
jne .LBB1_10
Красивое. Один цикл. Коротко и быстро.
Тем, кто не очень хорошо понимает ассемблер, возможно покажется странным, что нет условного перехода для организации вот этого выбора: sz == 0 ? 1 : sz.
В регистре RSI лежит значение переменной acc. Далее потребуется копия этого значения, поэтому оно записывается в регистр RCX.
mov rcx, rsi
В регистре RBP лежит адрес для перебора элементов массива sizes в обратном порядке. Здесь извлекаем элемент в переменную sz (регистр RDX) и сдвигаем итератор влево. Идём с шагом в 8 байт, т.к. каждый элемент типа int64_t занимает 8 байт.
mov rdx, qword ptr [rbp - 8]
add rbp, -8
Напомню, что RSI это acc. Записываем регистр в массив strides. В регистре RAX адрес элемента. Операция перехода к предыдущему элементу будет выполнена ниже (см. add rax, -8).
mov qword ptr [rax], rsi
Теперь самое интересное. Выполняется строчка acc *= (sz == 0 ? 1 : sz). Сравниваем, регистр RDX (sz) c 1.
cmp rdx, 1 // CF = перенос при sz - 1
Это работает так. Из RDX вычитается 1. Если возникает перенос, то выставляется флаг переноса CF. Получается, что CF = 1, если в RDX будет 0.
Это acc = sz. Мы затёрли значение переменной, но нестрашно, у нас есть её копия в RCX.
mov rsi, rdx // acc = sz
Если в sz был 0, то превратим его в 1. Для этого добавим флаг переноса CF. Если в sz не 0, то флаг переноса равен 0 и ничего не изменится. Вот так компилятор избежал условного перехода.
adc rsi, 0 // acc = acc + 0 + CF
Осталось выполнить два умножения, уменьшить RAX и продолжить цикл.
imul rsi, rcx // acc = acc * {копия acc в регистре RCX}
imul r12, rdx // ne = ne * sz
add rax, -8
cmp rbp, r13 // Достигли начала strides?
jne .LBB1_10 // Если нет, то goto LBB1_10
Есть какой-то другой альтернативный подход? Есть. Не знаю, догадался ли я до него сам или нет. К сожалению, после написания своего варианта кода, я поспешил поспрашивать, какие варианты могут предложить ИИ.
Варианты от DeepSeek были не лучше, а некоторые даже хуже исходного. Например, он сократил одну из реализаций кода ценой переворачивания массива.
// Разворачиваем strides обратно
std::reverse(strides.begin(), strides.end());
А вот одна из альтернативных реализаций от Claude Opus заслуживает внимания. Он обратил внимание, что можно выполнять не две, а одну последовательность умножений!
Если все элементы входного массива не нулевые, то по завершению цикла acc == ne. А если хотя бы один элемент был нулевой, то в конце можно просто поменять значение ne на ноль. Происходит вновь возвращение к флагу any_zero, но в более умном варианте. Используя эту идею, можно написать следующий код:
static TensorImpl make_contiguous_tensor(const std::vector<int64_t>& sizes) {
auto q = sizes.size();
std::vector<int64_t> strides(q);
int64_t acc = 1;
bool any_zero = false;
for (auto sz : std::ranges::views::reverse(sizes)) {
strides[--q] = acc;
acc *= (sz == 0 ? 1 : sz);
any_zero |= sz == 0;
}
const int64_t ne = any_zero ? 0 : acc;
....
}
.LBB1_7:
mov rdx, rsi
mov rsi, qword ptr [r13 - 8]
add r13, -8
mov qword ptr [rcx], rdx
test rsi, rsi
sete dil
cmp rsi, 1
adc rsi, 0
imul rsi, rdx
or al, dil
add rcx, -8
cmp r13, rbp
jne .LBB1_7
xor r13d, r13d
test al, 1
cmove r13, rsi
mov r14, r8
Будет ли код, в котором выполняется в два раза меньше умножений, более быстрым? Моё предсказание – необязательно. Процессор может выполнить два невзаимосвязанных умножения одновременно на разных конвейерах. Я делаю ставку, что мой вариант и вариант от Claude будут работать с практически одинаковой скоростью.
Давайте проверим замерами, как оно будет на самом деле в реальности. Пришлось немного заморочиться с тестами. Во-первых, надо было подавать такие данные, чтобы не возникало переполнение знаковых 64-битных переменных, так как это UB и замерам не будет доверия. Во-вторых, для честности, надо чередовать данные с нулём и без. В-третьих, цепочки входных данных должны быть разной длины.
Не утверждаю, что измерения выверены, но общее представление они дают. Скорость работы первого изначального алгоритма взята за единицу отсчёта. Использовал Clang с ключом -O2 и -m64.
Рефакторинг дал ускорение в среднем на 10 %. Я ждал, что будет побольше, в районе 20—30 %. Но 10 % — это тоже хорошо, учитывая, что они достигнуты упрощением, а не усложнением кода.
На этом разбор кода и его рефакторинг закончен. Был ли в этом смысл?
С точки зрения улучшения конкретно этого кода – нет. Это сгенерированные тесты. Некритично, что код длиннее и медленнее, чем он может быть.
С образовательной точки зрения – да. Неважно, создаётся код человеком или генерируется с помощью ИИ. Нужны эксперты, которые могут отличить хороший код от плохого. Быстрый он или нет. Безопасный он или нет.
Кому-то достаточно, что код работает, и неважно, что там. В некоторых случаях, например для прототипа, это нормально. Однако те, кто не интересуется архитектурой, оптимизацией, безопасностью, скорее всего, просто не видят и не понимают картину разработки и сопровождения больших программных продуктов. Нас ещё ждут новости о маленьких и больших провалах таких проектов.
ИИ — это мощный инструмент, но не волшебная палочка. Когда Claude подсказал мне альтернативный вариант алгоритма, это хороший пример, что ИИ можно использовать для исследований и усиления возможностей человека. Но точно также он легко нанесёт вред при бездумном использовании.
Если ты хочешь продолжать совершенствоваться как C++ специалист и создавать надёжное безопасное ПО, предлагаю заглянуть к нам сюда:
0