Иногда тормоза в коде выглядят как что-то сложное: тяжёлые алгоритмы, огромные базы данных, медленный диск. Но чаще всё намного банальнее — один неудачный цикл, который выполняется миллионы раз.

Недавно я столкнулся именно с такой ситуацией. Нужно было обработать большой лог-файл — около 8 ГБ — и извлечь из него статистику по пользователям. Скрипт работал почти 9 минут, хотя логика казалась предельно простой.

Разбор показал, что проблема была в одном месте.

Исходная задача

Есть большой лог:

timestamp user_id action

Например:

1690039200 421 login
1690039211 421 view
1690039220 182 login
1690039230 421 logout

Нужно посчитать количество действий каждого пользователя.

Наивная реализация выглядела примерно так.

def count_actions(file_path):
    users = []

    with open(file_path) as f:
        for line in f:
            user_id = line.split()[1]

            found = False
            for u in users:
                if u["id"] == user_id:
                    u["count"] += 1
                    found = True
                    break

            if not found:
                users.append({"id": user_id, "count": 1})

    return users

Код простой и понятный. Он даже работает корректно.

Но есть нюанс.

Где здесь проблема

Проблема в этом фрагменте.

for u in users:
    if u["id"] == user_id:

Мы ищем пользователя линейным поиском.

Если пользователей много, сложность становится:

O(n²)

Почему так происходит.

Для каждой строки:

  1. читаем user_id

  2. перебираем весь список пользователей

  3. проверяем совпадение

Если строк миллионы, этот цикл начинает работать катастрофически медленно.

Первый тест

Я взял лог на 10 миллионов строк.

Результат выполнения:

8 минут 47 секунд

Процессор почти всё время был загружен.

При профилировании стало видно, где происходит основная потеря времени.

import cProfile

cProfile.run("count_actions('log.txt')")

Самая тяжёлая функция — поиск пользователя в списке.

Самое простое решение

Очевидная оптимизация — заменить список на словарь.

Почему.

Словарь Python реализован как хеш-таблица, поэтому поиск происходит за:

O(1)

Перепишем код.

def count_actions(file_path):
    users = {}

    with open(file_path) as f:
        for line in f:
            user_id = line.split()[1]

            if user_id in users:
                users[user_id] += 1
            else:
                users[user_id] = 1

    return users

Теперь мы не перебираем всех пользователей.

Мы просто обращаемся к словарю по ключу.

Второй тест

Запускаем тот же лог.

12 секунд

Скрипт ускорился примерно в 42 раза.

Без изменения алгоритма обработки файла.
Без многопоточности.
Без C-расширений.

Просто убрали один цикл.

Можно ли ускорить ещё

Да. В Python есть ещё более удобный инструмент — defaultdict.

Он избавляет от проверки наличия ключа.

from collections import defaultdict

def count_actions(file_path):
    users = defaultdict(int)

    with open(file_path) as f:
        for line in f:
            user_id = line.split()[1]
            users[user_id] += 1

    return users

Код становится не только быстрее, но и чище.

Ещё одна маленькая оптимизация

split() разбивает всю строку.

Но нам нужен только второй элемент.

Можно использовать split с ограничением.

user_id = line.split(" ", 2)[1]

Это уменьшает количество создаваемых объектов.

На больших файлах это тоже заметно.

Итоговая версия

from collections import defaultdict

def count_actions(file_path):
    users = defaultdict(int)

    with open(file_path) as f:
        for line in f:
            parts = line.split(" ", 2)
            user_id = parts[1]
            users[user_id] += 1

    return users

Что показали тесты

версия

время

наивная

8 мин 47 сек

словарь

12 сек

defaultdict

10 сек

Разница — примерно 50×.

Почему такие вещи часто остаются незамеченными

Есть несколько причин.

1. На маленьких данных всё работает быстро

На файле из 1000 строк разница почти незаметна.

Проблема проявляется только на больших данных.

2. Код выглядит правильным

Логика читается легко.

Но читаемость и сложность алгоритма — разные вещи.

3. Python скрывает стоимость операций

Например:

x in list

выглядит как одна операция, но внутри происходит перебор.

Когда стоит задуматься об оптимизации

Есть простой сигнал.

Если в коде есть конструкция:

цикл внутри цикла

и количество данных может расти — почти всегда есть более эффективный способ.

Чаще всего это:

  • словари

  • множества

  • индексы

  • предварительная агрегация

Небольшой вывод

Иногда ускорение программы не требует сложных оптимизаций.

Достаточно задать один вопрос:

Какая сложность у этого участка кода?

В моём случае всё ускорилось в десятки раз после удаления одного цикла.

И это одна из самых частых причин медленных скриптов, которые я встречаю в Python-проектах.

Интересно, что почти все медленные Python-скрипты, которые я видел в реальных проектах, тормозили не из-за Python.

Проблема почти всегда была в алгоритмах.

Чаще всего встречаются три вещи:

  • линейный поиск в списке

  • лишние вложенные циклы

  • повторные вычисления, которые можно было кешировать

Python в таких случаях просто честно выполняет то, что ему написали.

Поэтому мне стало интересно:

какая самая странная оптимизация кода давала вам самый большой прирост скорости?

Иногда ведь достаточно удалить одну строчку — и программа начинает работать на порядок быстрее.

Делитесь примерами, думаю получится интересная коллекция инженерных историй.