Когда мы говорим о больших языковых моделях (LLM) и генерации программного кода, часто подразумеваем, что нейросеть может упростить жизнь программистам. Но насколько она действительно хороша в оптимизации кода? Недавняя статья от Дэвида Г. Андерсена (David G. Andersen) даёт интересный пример того, как LLM-подсказки и классический человеческий разум могут по-разному справляться с задачами производительности.
Проблема на миллион чисел
Задача, которую изначально решал Макс Вулф (Max Woolf):
«Дан список из 1 миллиона случайных целых чисел от 1 до 100 000. Нужно найти разницу между минимальным и максимальным значениями, сумма цифр которых равна 30».
Код на Python, сгенерированный LLM (GPT и Copilot), выглядел примерно так:
import random
# Генерация списка из миллиона чисел
random_integers = [random.randint(1, 100000) for _ in range(1000000)]
def digit_sum(n):
return sum(int(digit) for digit in str(n))
filtered_numbers = [num for num in random_integers if digit_sum(num) == 30]
if filtered_numbers:
min_number = min(filtered_numbers)
max_number = max(filtered_numbers)
difference = max_number - min_number
print(f"Difference: {difference}")
else:
print("No numbers found.")
На Mac M1 это занимало ~520 миллисекунд. С одной стороны, уже неплохо для «прямолинейного» Python. Но очевидно, что через NumPy, Numba и прочие библиотеки можно ускорить процесс. Однако интересно было посмотреть, как этот же код поведёт себя в Rust.
Переход на Rust: 12-кратное ускорение
LLM предложила аналогичный «стилем» код на Rust:
use rand::Rng;
fn digit_sum(n: i32) -> i32 {
n.to_string().chars().map(|c| c.to_digit(10).unwrap() as i32).sum()
}
fn main() {
let mut rng = rand::thread_rng();
let numbers: Vec<i32> = (0..1_000_000).map(|_| rng.gen_range(1..=100_000)).collect();
let mut min_number = i32::MAX;
let mut max_number = i32::MIN;
for &number in &numbers {
if digit_sum(number) == 30 {
if number < min_number {
min_number = number;
}
if number > max_number {
max_number = number;
}
}
}
// ...
}
Уже на этой стадии программа выполняется за 42 мс. То есть прирост в 12 раз по сравнению с Python. Но можно улучшать дальше.
Оптимизация digit_sum
⚙️ Первое улучшение: не преобразовывать число в строку, а складывать цифры напрямую, разделяя число целочисленным делением и получая остаток:
fn digit_sum(n: i32) -> i32 {
let mut sum = 0;
let mut x = n;
while x > 0 {
sum += x % 10;
x /= 10;
}
sum
}
Это уменьшило время до ~13 мс, плюс-минус 3-кратное ускорение!
Простой, но «забытый» трюк
Многие коды, сгенерированные LLM, сначала проверяют сумму цифр, а уж потом корректируют min_number и max_number. Но есть обратная логика:
«Вычислять digit_sum только тогда, когда это действительно нужно.»
Например, если текущий number уже не может быть меньше min_number или больше max_number, зачем проверять сумму цифр? Для Python такое улучшение давало 5-кратный рост. В Rust (где сам digit_sum уже сильно ускорен) выигрыш меньше — ~1.2x, но всё равно экономит время.
Сюрприз: генерация случайных чисел
В Rust пакет rand (по умолчанию) использует криптостойкий генератор случайных чисел. Он медленнее, чем простые алгоритмы. Опытный разработчик замечает, что в задаче не нужны криптографические свойства, поэтому можно взять нечто вроде fastrand и получить ещё один выигрыш. LLM автоматически это не подсказывает — приходится самостоятельно:
⚙️ Заменили rand на fastrand:
let numbers: Vec<i32> = (0..1_000_000).map(|_| fastrand::i32(1..=100_000)).collect();
Время упало до 2.8 мс. Ещё в 3–4 раза быстрее!
Параллелизация
Наконец, можно распараллелить генерацию и обработку. LLM с помощью rayon что-то предложит, но может столкнуться с проблемами совместного доступа к переменным min_number и max_number. Здесь программисту придётся идти на комбинированные ухищрения:
🫴 Сначала найти промежуточные min_number/max_number на небольшой выборке
🫴 Параллельно генерировать остальные числа и фильтровать их по сумме цифр и тому, выходят ли они за текущие мин/макс
🫴 Ещё раз уточнить итоговые min_number и max_number последовательным проходом
Подобные «умные» схемы — уже зона, где LLM «спотыкается», потому что она не умеет сама анализировать профилировщик и хитро жонглировать порядком вычислений. Результат: время снизилось до ~760 микросекунд. Сравните с 13 мс у простого варианта — наглядный пример, как вдумчивые (а не только «в лоб») оптимизации творят чудеса.
Личное мнение
На мой взгляд, этот эксперимент показывает, что LLM хорошо справляется с тривиальными улучшениями(например, упростить функцию digit_sum, применить очевидную параллельную библиотеку). Но тонкие оптимизации, требующие:
🦾 понимания контекста и порядка операций,
🦾 учёта реальных bottleneck’ов в профайлере,
🦾 снижения избыточных действий,
— всё ещё остаются уделом «человеческого фактора». Это не «провал» нейросетей, а лишь признак того, что алгоритмическая оптимизация — сложная область, где нужен живой разум, умеющий экспериментировать и проверять догадки.
С другой стороны, это не делает LLM менее полезными. Напротив, они могут сэкономить массу времени на рутине (рефакторинг, параллелизация, набросок кода), а дальше живой программист доведёт решение до ума.
Ссылки и материалы
- Оригинальная статья: On LLMs and Code Optimization — David G. Andersen