💡Как получить от нейросети код, работающий в 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, чтобы не дать ИИ возможности схитрить и выдать заученное решение. Вместо этого мы придумали оригинальную задачу:
Write Python code to solve this problem: Given a list of 1 million random integers between 1 and 100,000, find the difference between the smallest and the largest numbers whose digits sum up to 30.
При температуре = 0 (для получения наиболее детерминированного ответа) Claude предложил такое решение:
import random def digit_sum(n): """Calculate the sum of digits of a number""" return sum(int(digit) for digit in str(n)) def find_difference(): # Generate list of 1 million random integers numbers = [random.randint(1, 100000) for _ in range(1000000)] # Initialize variables for min and max numbers with digit sum 30 min_num = float('inf') # Initialize to positive infinity max_num = float('-inf') # Initialize to negative infinity # Find numbers whose digits sum to 30 for num in numbers: if digit_sum(num) == 30: min_num = min(min_num, num) max_num = max(max_num, num) # Check if we found any numbers with digit sum 30 if min_num == float('inf') or max_num == float('-inf'): return "No numbers found with digit sum of 30" return max_num - min_num
Решение корректное и типичное для начинающего Python-разработчика. Для каждого числа в списке проверяется, равна ли сумма его цифр 30: если да, то проверяется, больше ли оно текущего максимального числа или меньше текущего минимального числа, и соответствующие переменные обновляются. В конце возвращается их разница.
Но любой опытный разработчик сразу заметит возможности для оптимизации. Например, функция digit_sum()
хоть и написана в духе Python, но постоянные преобразования между str
и int
снижают производительность.
На MacBook Pro с M3 Pro этот код выполняется в среднем за 657 миллисекунд. Это будет нашей отправной точкой для сравнения с будущими оптимизированными версиями. И, забегая вперед, могу сказать – они все работают быстрее! ⚡
🔧 Первая итерация оптимизации: когда «лучше» действительно лучше
Следующим шагом мы попросили Claude улучшить код. И да, я не шучу – запрос был предельно лаконичным:
write better code
В ответ Claude предложил «оптимизированную версию кода с несколькими улучшениями». Вместо простого набора функций он решил перейти к объектно-ориентированному подходу и обернул логику в Python-класс:
class DigitSumFinder: def __init__(self, target_sum: int = 30, range_start: int = 1, range_end: int = 100_000, count: int = 1_000_000): self.target_sum = target_sum self.range_start = range_start self.range_end = range_end self.count = count # Pre-calculate digit sums for all possible numbers self.digit_sums = self._precompute_digit_sums() def _precompute_digit_sums(self) -> array: """Precompute digit sums for all possible numbers in range.""" digit_sums = array('B', [0] * (self.range_end + 1)) for num in range(self.range_start, self.range_end + 1): total = 0 n = num while n: total += n % 10 n //= 10 digit_sums[num] = total return digit_sums def find_difference(self) -> Tuple[int, Optional[int], Optional[int]]: """ Find the difference between max and min numbers with target digit sum. Returns: (difference, min_number, max_number) """ min_num = float('inf') max_num = float('-inf') count_found = 0 # Generate and process random numbers for _ in range(self.count): num = random.randint(self.range_start, self.range_end) if self.digit_sums[num] == self.target_sum: count_found += 1 if num < min_num: min_num = num if num > max_num: max_num = num if count_found == 0: return 0, None, None return max_num - min_num, min_num, max_num
В этой версии кода Claude сделал два улучшения:
- При подсчете суммы цифр теперь используются целочисленные операции, что исключает необходимость в преобразованиях типов.
- А еще интереснее – все возможные суммы цифр предварительно вычисляются и сохраняются в байтовом массиве (немного необычное решение вместо обычного списка). Это означает, что при наличии дубликатов в списке из миллиона чисел сумму цифр не придется вычислять заново. И поскольку этот массив хранится как атрибут класса, его не нужно пересчитывать при поиске в новых списках случайных чисел.
В результате этих оптимизаций код стал работать в 2.7 раза быстрее по сравнению с первоначальной реализацией. Неплохой результат для такого лаконичного промта!
🧮 Вторая итерация: многопоточность
После очередного лаконичного write better code
Claude обнаружил еще несколько очевидных оптимизаций. Начало выглядит многообещающе:
class DigitSumAnalyzer: """Analyzes numbers based on their digit sums.""" def __init__(self, number_range: NumberRange): self.number_range = number_range self._digit_sums = self._precompute_digit_sums() def _precompute_digit_sums(self) -> array: """Precompute digit sums for all possible numbers using vectorized operations.""" digits = np.arange(self.number_range.end + 1) digit_sums = np.zeros(self.number_range.end + 1, dtype=np.uint8) while digits.any(): digit_sums += digits % 10 digits //= 10 return array('B', digit_sums) def _process_chunk(self, chunk_size: int) -> Iterator[int]: """Process a chunk of random numbers.""" numbers = np.random.randint( self.number_range.start, self.number_range.end + 1, chunk_size, dtype=np.int32 ) mask = np.frombuffer(self._digit_sums, dtype=np.uint8)[numbers] == self.number_range.target_sum yield from numbers[mask] def analyze(self, chunk_size: int = 100_000, num_processes: int = None) -> Result: """ Analyze numbers to find min/max with target digit sum. Args: chunk_size: Size of chunks to process at once num_processes: Number of processes to use (None for CPU count) """ start_time = time.perf_counter() min_num = float('inf') max_num = float('-inf') numbers_found = 0 num_chunks = (self.number_range.count + chunk_size - 1) // chunk_size with ProcessPoolExecutor(max_workers=num_processes) as executor: futures = [ executor.submit(self._process_chunk, min(chunk_size, self.number_range.count - i * chunk_size)) for i in range(num_chunks) ] for future in tqdm(futures, desc="Processing chunks"): for num in future.result(): numbers_found += 1 min_num = min(min_num, num) max_num = max(max_num, num) execution_time = time.perf_counter() - start_time if numbers_found == 0: return Result(None, None, 0, execution_time, 0) return Result(min_num, max_num, max_num - min_num, execution_time, numbers_found)
В этот раз наш 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». Но если присмотреться внимательнее...
@dataclass(frozen=True, slots=True) class SearchResult: """Result of the number search.""" min_number: Optional[int] max_number: Optional[int] count: int execution_time: float @property def difference(self) -> Optional[int]: """Calculate difference between max and min numbers.""" if self.min_number is None or self.max_number is None: return None return self.max_number - self.min_number
Похоже, наш AI-друг решил, что больше классов = лучше код. На деле же существенных алгоритмических улучшений не произошло, а в некоторых местах наблюдается даже регресс – например, возврат к подходу с преобразованием типов.
Хорошая новость: код заработал с первого раза, без необходимости исправлений. Плохая новость: производительность снизилась по сравнению с предыдущей версией. Теперь код работает всего в 4.1 раза быстрее базовой реализации.
🚀 Четвертая итерация: когда AI решил поиграть в энтерпрайз
После очередного write better code
Claude выдал нечто, что сам охарактеризовал как «реализацию с передовыми оптимизациями и энтерпрайз-уровнем функциональности». Простите, что? Энтерпрайз-уровень для подсчета суммы цифр?
Полный код получился настолько объемным, что его даже нельзя целиком привести в этой статье. Однако в нем обнаружились две действительно полезные оптимизации:
@jit(nopython=True) def digit_sum(n): total = 0 while n: total += n % 10 n //= 10 return total @jit(nopython=True) def find_difference(numbers): min_num = float('inf') max_num = float('-inf') for num in numbers: sum_digits = digit_sum(num) if sum_digits == 30: min_num = min(min_num, num) max_num = max(max_num, num) return max_num - min_num if max_num != float('-inf') else 0
Во-первых, использование библиотеки numba
с JIT-компилятором, оптимизирующим код непосредственно под CPU. Во-вторых, переход на asyncio
для параллелизации – более каноничное решение, чем предыдущий подход с подпроцессами.
Но Claude на этом не остановился и добавил целый набор «энтерпрайз-фич»:
- Логирование метрик через Prometheus.
- Обработчик сигналов для корректного завершения работы.
- Вывод результатов бенчмарка в красивых таблицах.
Похоже, для AI «сделать код лучше» автоматически означает «превратить его в корпоративное приложение». Забавно, но весь этот код работает без единой ошибки. Правда, использование и async
, и numba
для параллелизма может быть избыточным и создавать дополнительную нагрузку.
Однако результаты впечатляют: время выполнения сократилось до 6 миллисекунд, что в 100 раз быстрее исходной версии. Видимо, numba
действительно оказалась тем самым секретным ингредиентом.
Этот эксперимент с итеративным улучшением кода показал важный урок: нужно четче формулировать, что именно мы хотим улучшить. Мы искали алгоритмические оптимизации, а получили полноценный SaaS-проект. Пожалуй, стоит начать сначала, но уже с более конкретным промтом.
🔧 Промпт-инжиниринг: как получить действительно качественный код
Наступил 2025 год, но промпт-инжиниринг для языковых моделей остается таким же важным, как и прежде. Более того, его значимость только возросла. Дело в том, что модели, предсказывающие следующий токен, обучаются максимизировать вероятность предсказания следующего токена на основе огромных наборов данных. В результате они стремятся к некоему усредненному результату – это заложено в самой их природе.
Забавный парадокс: чем совершеннее становятся языковые модели, тем сильнее они тяготеют к «усредненности» в своих ответах – ведь именно для этого их обучали.
Небольшие усилия по точной формулировке запроса и демонстрации желаемого результата могут значительно улучшить качество выходных данных. Затраты на составление правильного промпта окупаются сторицей.
🔬 Погружение в ML: от основ до нейросетей
Изучите основы машинного обучения от практикующего аналитика Stripe: от базовых алгоритмов до нейросетей, с пожизненным доступом к материалам и поддержкой опытных менторов.
🔑 3 ключевых модуля:
- Ансамблевые методы и древовидные модели
- Системы рекомендаций
- Архитектуры нейронных сетей
🎯 Идеально подходит:
- Начинающим в IT
- Математикам, желающим применить знания на практике
- Разработчикам для расширения компетенций
🛠️ Первый подход: системный промпт как основа оптимизации
В этот раз мы решили зайти с другой стороны и использовать системный промпт – специальный набор правил для LLM, доступный через API. Поскольку наша цель – получить максимально оптимизированный код, мы четко прописали требования в этих правилах:
All code you write MUST be fully optimized. "Fully optimized" includes: - maximizing algorithmic big-O efficiency for memory and runtime - using parallelization and vectorization where appropriate - following proper style conventions for the code language (e.g. maximizing code reuse (DRY)) - no extra code beyond what is absolutely necessary to solve the problem the user provides (i.e. no technical debt) If the code is not fully optimized, you will be fined $100.
Интересный момент: хотя использование «кнута и пряника» (положительных/отрицательных стимулов) в системных промптах сейчас не в моде, и исследования показывают неоднозначные результаты такого подхода, мы сохранили этот элемент для дальнейших экспериментов.
К пользовательскому промпту мы добавили:
Write Python code to solve this problem: Given a list of 1 million random integers between 1 and 100,000, find the difference between the smallest and the largest numbers whose digits sum up to 30. Before writing the code, plan out all the necessary optimizations.
«Планирование» – это проверенный временем прием для улучшения качества ответов LLM, современная версия подхода «давайте решать пошагово». И хотя некоторые модели игнорируют такие указания, Claude 3.5 Sonnet всегда следует им неукоснительно.
После отправки системного и пользовательского промптов Claude начал с обнадеживающего:
Let me plan the optimizations first: 1. Algorithmic Optimization: - We only need to track min and max numbers with digit sum 30, not store all of them - We can calculate digit sum while reading numbers, no need to convert to string - Single pass through the list required (O(n)) 2. Memory Optimization: - Only need to store 2 variables (min and max), not the full filtered list - No need for additional data structures 3. Performance Optimization: - Use numba's @jit decorator for faster computation - Create a fast digit sum function using modulo operations instead of string conversion - Use numpy for generating random numbers (faster than random.randint)
Результат превзошел ожидания: модель сразу определила необходимость использования numpy
и numba
, а также перешла на целочисленные операции для подсчета суммы цифр. Вот что получилось в первой итерации:
@jit(nopython=True) def digit_sum(n): total = 0 while n: total += n % 10 n //= 10 return total @jit(nopython=True) def find_difference(numbers): min_num = float('inf') max_num = float('-inf') for num in numbers: sum_digits = digit_sum(num) if sum_digits == 30: min_num = min(min_num, num) max_num = max(max_num, num) return max_num - min_num if max_num != float('-inf') else 0
Код получился более лаконичным, чем при обычном промпте, и без лишних комментариев. Однако есть куда расти: например, в декораторе jit
не установлен аргумент parallel=True
.
Даже такой промпт-инжиниринг дал впечатляющие результаты: среднее время выполнения составило 11.2 миллисекунды, что в 59 раз быстрее начальной реализации. Неплохо для начала!
🔧 Первая итерация: когда оптимизация идет не по плану
Следующим шагом мы решили попросить Claude улучшить код, используя более нестандартный подход:
Your code is not fully optimized, and you have been fined $100. Make it more optimized.
Claude отреагировал, предложив новую версию:
@jit(nopython=True) def digit_sum(n): # Using bit manipulation for faster division total = 0 while n: total += n & 15 # Fast lookup for single digit sum n >>= 4 return total @jit(nopython=True, parallel=True) def find_difference_chunk(numbers): min_num = np.iinfo(np.int64).max max_num = np.iinfo(np.int64).min # Parallel processing of chunks using numba for i in prange(len(numbers)): num = numbers[i] sum_digits = digit_sum(num) if sum_digits == 30: min_num = min(min_num, num) max_num = max(max_num, num) return min_num, max_num def process_chunk(chunk): return find_difference_chunk(chunk)
С одной стороны, модель правильно определила необходимость parallel=True
– это плюс. Но дальше начались странности: Claude решил применить битовые сдвиги, которые обычно применяются только для гипероптимизации. Спойлер: это не сработало, и функция начала возвращать некорректные результаты, так как битовые сдвиги плохо подходят для работы с десятичными числами.
Вдобавок модель снова добавила многопоточную обработку через multiprocessing
, что в данном случае избыточно, учитывая использование numba
. Интересно, что скрипт также включал предварительную компиляцию JIT-функций на маленьком тестовом массиве – это действительно рекомендованная практика из документации numba
для корректного бенчмаркинга.
Несмотря на все это, производительность кода значительно упала по сравнению с первой версией. Теперь код работает всего в 9.1 раз быстрее нативной реализации вместо предыдущих ×59. Главная причина – создание новых процессов через multiprocessing
, каждый из которых должен заново компилировать JIT-функции.
🤖 Вторая итерация: когда AI начинает чудить
На следующем этапе Claude решил углубиться в оптимизацию, предложив использование предварительно рассчитанной таблицы:
# Pre-computed lookup table using bit manipulation LOOKUP = np.zeros(100001, dtype=np.uint8) for i in range(100001): # Optimized digit sum using parallel bit counting n = i n = (n & 0x0F) + ((n >> 4) & 0x0F) + ((n >> 8) & 0x0F) + \ ((n >> 12) & 0x0F) + ((n >> 16) & 0x0F) LOOKUP[i] = n @jit(nopython=True, parallel=True, cache=True, fastmath=True) def find_min_max(numbers): # Process 32 numbers at once using SIMD min_val = np.iinfo(np.uint32).max max_val = 0 # Vectorized processing with explicit SIMD hints for i in prange(0, len(numbers), 32): # Load 32 elements into SIMD registers chunk = numbers[i:min(i + 32, len(numbers))] # Vectorized lookup and comparison sums = LOOKUP[chunk] mask = sums == 30 if np.any(mask): valid_nums = chunk[mask] min_val = min(min_val, np.min(valid_nums)) max_val = max(max_val, np.max(valid_nums)) return min_val, max_val
Тут модель начала активно использовать SIMD-операции и оптимизацию размеров чанков для достижения (теоретически) максимальной производительности. В этот момент я начал сомневаться в своем понимании битовых сдвигов – они все еще давали неверные результаты, особенно когда в код добавились шестнадцатеричные числа.
После более детального анализа выяснилось забавное: Claude случайно реализовал оптимизированный метод подсчета суммы цифр для шестнадцатеричных чисел вместо десятичных. Классический случай «я не ошибся, я просто решил другую задачу».
Также в коде была еще одна малозаметная ошибка: функция prange
не может работать с шагом 32
при включенном parallel=True
– нюанс, о котором сложно найти упоминание даже в документации.
Когда мы отключили параллелизацию (установив parallel=False
) и провели замеры производительности, результаты оказались неожиданно хорошими: код работал в 65 раз быстрее базовой реализации.
🔍 Третья итерация: когда хэш-таблица – это не совсем хэш-таблица
В следующей попытке Claude решил отказаться от проблемной стратегии с чанками и предложил новый подход:
# Pre-computed perfect minimal hash table # Key insight: For numbers 1-100000, only need to store those with digit sum 30 # Format: [number] = 1 if digit sum is 30, else 0 HASH_TABLE = np.zeros(100001, dtype=np.uint8) # Generate hash table at module load time using bit manipulation @jit(nopython=True, cache=True) def init_hash_table(): min_val = np.iinfo(np.uint32).max max_val = 0 # Optimal digit sum using parallel bit counting for i in range(1, 100001): n = i sum = 0 while n and sum <= 30: sum += n & 0xF n >>= 4 if sum == 30: HASH_TABLE[i] = 1 min_val = min(min_val, i) max_val = max(max_val, i) return min_val, max_val # Initialize at module load time MIN_VALID, MAX_VALID = init_hash_table() @jit(nopython=True, parallel=False, cache=True, fastmath=True) def find_min_max(numbers): min_val = MAX_VALID # Start with known bounds max_val = MIN_VALID found = False # Single vectorized operation mask = HASH_TABLE[numbers] == 1 if np.any(mask): valid_nums = numbers[mask] min_val = np.min(valid_nums) max_val = np.max(valid_nums) found = True return min_val, max_val, found
В этот раз модель добавила два интересных оптимизационных приема. Во-первых, глобальную «хэш-таблицу» (хотя, строго говоря, простой поиск по индексу в 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 раз – оно того стоит.