💡Как получить от нейросети код, работающий в 100 раз быстрее
Интересный эксперимент провели с Claude 3.5 Sonnet: попытались улучшить простой алгоритм на Python, раз за разом прося ИИ «написать код лучше». Задача была несложная – найти разницу между максимальным и минимальным числами в массиве, сумма цифр которых равна 30. Казалось бы, что тут можно улучшать?
Несмотря на то, что я давно занимаюсь исследованиями и разработкой инструментов на базе языковых моделей (еще до появления ChatGPT), я как-то не особо жаловал AI-ассистенты вроде GitHub Copilot. Постоянное переключение контекста между «о, ИИ дополнил мой код, круто» и «а правильный ли это код или очередная галлюцинация?» создавало столько отвлекающих факторов, что прирост продуктивности стремился к нулю. И это еще не учитывая высокую стоимость использования таких LLM.
Но Claude 3.5 Sonnet заставил меня пересмотреть свое мнение. Благодаря какому-то особому подходу Anthropic к обучению модели, последняя версия Claude 3.5 Sonnet (claude-3-5-sonnet-20241022) демонстрирует поразительную точность в следовании промптам любого типа, особенно когда дело касается программирования. Тесты, связанные с программированием, подтверждают: в сравнении Claude 3.5 Sonnet и GPT-4o победу одерживает Claude. И мой личный опыт работы с различными техническими и творческими задачами это только подтверждает.
Постановка задачи
Для этого эксперимента мы дадим Claude 3.5 Sonnet задачу по программированию в стиле собеседования на Python. Задача должна быть достаточно простой, чтобы с ней мог справиться начинающий разработчик, и ее можно было оптимизировать. Такой подход хорошо отражает то, как обычные разработчики используют LLM в повседневной работе.
Важный момент: мы специально не брали задачу с LeetCode или HackerRank, чтобы не дать ИИ возможности схитрить и выдать заученное решение. Вместо этого мы придумали оригинальную задачу:
При температуре = 0 (для получения наиболее детерминированного ответа) Claude предложил такое решение:
Решение корректное и типичное для начинающего Python-разработчика. Для каждого числа в списке проверяется, равна ли сумма его цифр 30: если да, то проверяется, больше ли оно текущего максимального числа или меньше текущего минимального числа, и соответствующие переменные обновляются. В конце возвращается их разница.
Но любой опытный разработчик сразу заметит возможности для оптимизации. Например, функция digit_sum()
хоть и написана в духе Python, но постоянные преобразования между str
и int
снижают производительность.
На MacBook Pro с M3 Pro этот код выполняется в среднем за 657 миллисекунд. Это будет нашей отправной точкой для сравнения с будущими оптимизированными версиями. И, забегая вперед, могу сказать – они все работают быстрее! ⚡
🔧 Первая итерация оптимизации: когда «лучше» действительно лучше
Следующим шагом мы попросили Claude улучшить код. И да, я не шучу – запрос был предельно лаконичным:
В ответ Claude предложил «оптимизированную версию кода с несколькими улучшениями». Вместо простого набора функций он решил перейти к объектно-ориентированному подходу и обернул логику в Python-класс:
В этой версии кода Claude сделал два улучшения:
- При подсчете суммы цифр теперь используются целочисленные операции, что исключает необходимость в преобразованиях типов.
- А еще интереснее – все возможные суммы цифр предварительно вычисляются и сохраняются в байтовом массиве (немного необычное решение вместо обычного списка). Это означает, что при наличии дубликатов в списке из миллиона чисел сумму цифр не придется вычислять заново. И поскольку этот массив хранится как атрибут класса, его не нужно пересчитывать при поиске в новых списках случайных чисел.
В результате этих оптимизаций код стал работать в 2.7 раза быстрее по сравнению с первоначальной реализацией. Неплохой результат для такого лаконичного промта!
🧮 Вторая итерация: многопоточность
После очередного лаконичного write better code
Claude обнаружил еще несколько очевидных оптимизаций. Начало выглядит многообещающе:
В этот раз наш AI-помощник добавил два существенных улучшения, наконец-то осознав, что задача прекрасно поддается параллельной обработке:
- Многопоточность через библиотеку
concurrent-futures
, разбивающую большой список на независимо обрабатываемые части. - Векторизованные операции
numpy
, которые работают значительно быстрее базовых операций Python. Особого внимания заслуживает функция_precompute_digit_sums()
с ее векторизованным подсчетом сумм цифр. Условиеwhile digits.any():
выглядит как код из другой вселенной, но работает безупречно.
Впрочем, в этой реализации параллелизации есть подводные камни: она порождает подпроцессы, что вызывает ряд неприятных проблем. Код нельзя запустить напрямую, он требует конструкции if __name__ == '__main__':
, что существенно ограничивает его применимость. Даже при запуске отдельным скриптом появляется ошибка "Error: cannot pickle 'generator' object"
из-за использования yield from numbers[mask]
(кстати, этот генератор совершенно не нужен, достаточно простого return numbers[mask]
). Вдобавок код смешивает разные типы данных numpy
, что приводит к ошибкам – установка всех типов как np.int32
решает проблему.
После исправления этих недочетов код стал работать в 5.1 раза быстрее исходной версии. Неплохой прирост производительности, хотя и ценой некоторого усложнения кода.
🤔 Третья итерация: когда оптимизация идет не туда
После очередной просьбы об улучшении кода Claude выдал то, что сам назвал «более совершенной и оптимизированной версией с использованием продвинутых техник и современных возможностей Python». Но если присмотреться внимательнее...
Похоже, наш AI-друг решил, что больше классов = лучше код. На деле же существенных алгоритмических улучшений не произошло, а в некоторых местах наблюдается даже регресс – например, возврат к подходу с преобразованием типов.
Хорошая новость: код заработал с первого раза, без необходимости исправлений. Плохая новость: производительность снизилась по сравнению с предыдущей версией. Теперь код работает всего в 4.1 раза быстрее базовой реализации.
🚀 Четвертая итерация: когда AI решил поиграть в энтерпрайз
После очередного write better code
Claude выдал нечто, что сам охарактеризовал как «реализацию с передовыми оптимизациями и энтерпрайз-уровнем функциональности». Простите, что? Энтерпрайз-уровень для подсчета суммы цифр?
Полный код получился настолько объемным, что его даже нельзя целиком привести в этой статье. Однако в нем обнаружились две действительно полезные оптимизации:
Во-первых, использование библиотеки numba
с JIT-компилятором, оптимизирующим код непосредственно под CPU. Во-вторых, переход на asyncio
для параллелизации – более каноничное решение, чем предыдущий подход с подпроцессами.
Но Claude на этом не остановился и добавил целый набор «энтерпрайз-фич»:
- Логирование метрик через Prometheus.
- Обработчик сигналов для корректного завершения работы.
- Вывод результатов бенчмарка в красивых таблицах.
Похоже, для AI «сделать код лучше» автоматически означает «превратить его в корпоративное приложение». Забавно, но весь этот код работает без единой ошибки. Правда, использование и async
, и numba
для параллелизма может быть избыточным и создавать дополнительную нагрузку.
Однако результаты впечатляют: время выполнения сократилось до 6 миллисекунд, что в 100 раз быстрее исходной версии. Видимо, numba
действительно оказалась тем самым секретным ингредиентом.
Этот эксперимент с итеративным улучшением кода показал важный урок: нужно четче формулировать, что именно мы хотим улучшить. Мы искали алгоритмические оптимизации, а получили полноценный SaaS-проект. Пожалуй, стоит начать сначала, но уже с более конкретным промтом.
🔧 Промпт-инжиниринг: как получить действительно качественный код
Наступил 2025 год, но промпт-инжиниринг для языковых моделей остается таким же важным, как и прежде. Более того, его значимость только возросла. Дело в том, что модели, предсказывающие следующий токен, обучаются максимизировать вероятность предсказания следующего токена на основе огромных наборов данных. В результате они стремятся к некоему усредненному результату – это заложено в самой их природе.
Забавный парадокс: чем совершеннее становятся языковые модели, тем сильнее они тяготеют к «усредненности» в своих ответах – ведь именно для этого их обучали.
Небольшие усилия по точной формулировке запроса и демонстрации желаемого результата могут значительно улучшить качество выходных данных. Затраты на составление правильного промпта окупаются сторицей.
🔬 Погружение в ML: от основ до нейросетей
Изучите основы машинного обучения от практикующего аналитика Stripe: от базовых алгоритмов до нейросетей, с пожизненным доступом к материалам и поддержкой опытных менторов.
🔑 3 ключевых модуля:
- Ансамблевые методы и древовидные модели
- Системы рекомендаций
- Архитектуры нейронных сетей
🎯 Идеально подходит:
- Начинающим в IT
- Математикам, желающим применить знания на практике
- Разработчикам для расширения компетенций
🛠️ Первый подход: системный промпт как основа оптимизации
В этот раз мы решили зайти с другой стороны и использовать системный промпт – специальный набор правил для LLM, доступный через API. Поскольку наша цель – получить максимально оптимизированный код, мы четко прописали требования в этих правилах:
Интересный момент: хотя использование «кнута и пряника» (положительных/отрицательных стимулов) в системных промптах сейчас не в моде, и исследования показывают неоднозначные результаты такого подхода, мы сохранили этот элемент для дальнейших экспериментов.
К пользовательскому промпту мы добавили:
«Планирование» – это проверенный временем прием для улучшения качества ответов LLM, современная версия подхода «давайте решать пошагово». И хотя некоторые модели игнорируют такие указания, Claude 3.5 Sonnet всегда следует им неукоснительно.
После отправки системного и пользовательского промптов Claude начал с обнадеживающего:
Результат превзошел ожидания: модель сразу определила необходимость использования numpy
и numba
, а также перешла на целочисленные операции для подсчета суммы цифр. Вот что получилось в первой итерации:
Код получился более лаконичным, чем при обычном промпте, и без лишних комментариев. Однако есть куда расти: например, в декораторе jit
не установлен аргумент parallel=True
.
Даже такой промпт-инжиниринг дал впечатляющие результаты: среднее время выполнения составило 11.2 миллисекунды, что в 59 раз быстрее начальной реализации. Неплохо для начала!
🔧 Первая итерация: когда оптимизация идет не по плану
Следующим шагом мы решили попросить Claude улучшить код, используя более нестандартный подход:
Claude отреагировал, предложив новую версию:
С одной стороны, модель правильно определила необходимость parallel=True
– это плюс. Но дальше начались странности: Claude решил применить битовые сдвиги, которые обычно применяются только для гипероптимизации. Спойлер: это не сработало, и функция начала возвращать некорректные результаты, так как битовые сдвиги плохо подходят для работы с десятичными числами.
Вдобавок модель снова добавила многопоточную обработку через multiprocessing
, что в данном случае избыточно, учитывая использование numba
. Интересно, что скрипт также включал предварительную компиляцию JIT-функций на маленьком тестовом массиве – это действительно рекомендованная практика из документации numba
для корректного бенчмаркинга.
Несмотря на все это, производительность кода значительно упала по сравнению с первой версией. Теперь код работает всего в 9.1 раз быстрее нативной реализации вместо предыдущих ×59. Главная причина – создание новых процессов через multiprocessing
, каждый из которых должен заново компилировать JIT-функции.
🤖 Вторая итерация: когда AI начинает чудить
На следующем этапе Claude решил углубиться в оптимизацию, предложив использование предварительно рассчитанной таблицы:
Тут модель начала активно использовать SIMD-операции и оптимизацию размеров чанков для достижения (теоретически) максимальной производительности. В этот момент я начал сомневаться в своем понимании битовых сдвигов – они все еще давали неверные результаты, особенно когда в код добавились шестнадцатеричные числа.
После более детального анализа выяснилось забавное: Claude случайно реализовал оптимизированный метод подсчета суммы цифр для шестнадцатеричных чисел вместо десятичных. Классический случай «я не ошибся, я просто решил другую задачу».
Также в коде была еще одна малозаметная ошибка: функция prange
не может работать с шагом 32
при включенном parallel=True
– нюанс, о котором сложно найти упоминание даже в документации.
Когда мы отключили параллелизацию (установив parallel=False
) и провели замеры производительности, результаты оказались неожиданно хорошими: код работал в 65 раз быстрее базовой реализации.
🔍 Третья итерация: когда хэш-таблица – это не совсем хэш-таблица
В следующей попытке Claude решил отказаться от проблемной стратегии с чанками и предложил новый подход:
В этот раз модель добавила два интересных оптимизационных приема. Во-первых, глобальную «хэш-таблицу» (хотя, строго говоря, простой поиск по индексу в numpy-массиве сложно назвать настоящей хэш-таблицей). Во-вторых, микрооптимизацию: если при суммировании цифр результат превышает 30, можно сразу прекратить подсчет, так как число точно не подходит под условия задачи.
Однако тут нас поджидал коварный баг, о котором в интернете почти нет информации: объекты вне JIT-компилируемых функций Numba доступны только для чтения. А наша хэш-таблица создавалась снаружи JIT-функции, но модифицировалась внутри нее – отсюда и возникала загадочная ошибка.
После небольшого рефакторинга, когда мы перенесли инициализацию хэш-таблицы внутрь JIT-функции, код наконец заработал. И не просто заработал, а показал впечатляющий результат: производительность выросла в 100 раз по сравнению с базовой реализацией.
🔧 Четвертая итерация: возвращение к истокам
На этом этапе Claude заявил, что код достиг «теоретически минимальной временной сложности для данной задачи».
Когда я попросил исправить только проблему с подсчетом суммы цифр, модель вернулась к ранее использованной целочисленной реализации, не трогая хэш таблицу. После корректировки хеш-тиблицы код наконец-то начал выдавать правильные результаты. Правда, пришлось немного пожертвовать производительностью – отказ от битовых сдвигов привел к тому, что код теперь работает «всего лишь» в 95 раз быстрее базовой версии.
Итоги
Просьба к LLM «написать код лучше» действительно приводит к улучшениям, хотя все зависит от того, что именно считать «лучше». Промпт-инженерия позволила быстрее и стабильнее повышать производительность, но при этом чаще вносила незаметные баги – все-таки LLM не оптимизированы для генерации высокопроизводительного кода. Как бы не расхваливали AI-энтузиасты «магию» LLM, без человеческого участия пока никуда.
Удивительно, но Claude 3.5 Sonnet упустил несколько очевидных оптимизаций. Например, он не рассмотрел статистический подход: при генерации миллиона чисел в диапазоне от 1 до 1 000 000 неизбежно появятся дубликаты, которые можно было бы отфильтровать через set()
или numpy.unique()
. Также не была предложена сортировка списка по возрастанию, что позволило бы искать минимум и максимум без полного перебора (хотя векторизованный подход все же практичнее).
Конечно, LLM пока не заменят разработчиков – нужен серьезный инженерный опыт, чтобы отличить действительно хорошие идеи от посредственных. Даже имея доступ к огромному количеству кода в интернете, без правильных подсказок модели не могут отличить средний код от высокопроизводительного. Реальные системы намного сложнее задачек с собеседований, но если простой цикл с запросами к Claude может дать подсказку для ускорения кода в 100 раз – оно того стоит.