Что за зверь такой?

Мы привыкли думать абстракциями. Нас учили, что мир состоит из объектов: у «Собаки» есть метод Bark(), а у «Пользователя» — поле Email. Мы тратим недели на споры о чистоте интерфейсов и иерархии наследования, свято веря, что инкапсуляция — это ключ к успеху. Но пока мы строим эти ментальные замки, наш процессор... скучает.

Современный CPU — это невероятно мощный вычислительный монстр, способный обрабатывать миллиарды операций в секунду. Но у него есть ахиллесова пята — память. Пока ваш код прыгает по указателям от одного объекта в куче к другому, процессор простаивает в ожидании данных, совершая те самые «cache misses».

Data-Oriented Design (DOD) — это не просто очередной паттерн. Это «таблетка реальности», которая предлагает перестать проектировать программы вокруг сущностей и начать проектировать их вокруг данных.

В этой статье мы разберем, почему классическое ООП часто работает против производительности, как помочь кэшу процессора полюбить ваш код и почему в 2024 году пора вспомнить, что программа — это просто трансформация одних байтов в другие.

Сравним два примера

class Animal:
    def __init__(self, name, x, y):
        self.name = name
        self.x = x
        self.y = y

    def update_position(self):
        # Какая-то логика движения(наверное)
        self.x += 1
        self.y += 1

class Cat(Animal):
    def speak(self):
        return "Мяу"

class Dog(Animal):
    def speak(self):
        return "Гав"

# Создаем зоопарк в памяти
zoo = [Cat(f"Кот {i}", i, i) if i % 2 == 0 else Dog(f"Пес {i}", i, i) 
       for i in range(100000)]

# Худший кошмар для процессора:
for animal in zoo:
    animal.update_position()

ТАДАМ! Это классика современной разработки, давайте я объясню:

Когда мы запускаем этот цикл, мы заставляем процессор выполнять «квест» вместо вычислений. Вот три всадника апокалипсиса производительности в этом примере:

1. Указательный ад (Pointer Chasing)

В списке zoo лежат не сами объекты, а адреса (указатели) на них.

  • Процессор читает адрес из списка.

  • Прыгает по этому адресу в другое место памяти, чтобы найти объект Cat.

  • Внутри объекта он находит еще один указатель на словарь атрибутов (__dict__).

  • Прыгает снова, чтобы найти там значение x.
    Каждый такой «прыжок» — это риск Cache Miss, когда данные приходится ждать из медленной основной памяти.

2. Раздутые объекты (Object Overhead)

Вместо двух чисел float (по 8 байт каждое), процессор вынужден тащить в кэш «бегемота»:

  • Заголовок объекта: ссылка на тип Cat, счетчик ссылок (Reference Counting).

  • Словарь атрибутов: Python хранит self.nameself.x и self.y в хэш-таблице.

  • Строки: Имя «Кот 123» — это еще один отдельный объект в памяти.
    В итоге, чтобы просто прибавить единицу к координате, процессор загружает в кэш сотни байт мусора, который в данный момент не нужен.

3. Убийство кэш-линии

Процессор забирает данные из памяти блоками по 64 байта (кэш-линии).

  • В эффективном коде в одну линию влезает 8-16 координат.

  • В твоем коде из-за того, что объекты Python огромные и разбросаны по памяти, в одну кэш-линию попадает ноль полезной информации для следующей итерации цикла. Каждое новое животное — это новый поход в RAM.

Проще говоря, это ужас для процессора и тут не спасет никакая асинхронность с многопоточность.

Вот реализация через DOD(Это очень грубо получилось и ДАЛЕКООО не идеально):

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

// Вместо класса Animal — плотные массивы данных
typedef struct {
    float *x;        // Все координаты X лежат в одном блоке памяти
    float *y;        // Все координаты Y — в другом
    int *types;      // Типы (0-кот, 1-пес) — в третьем
    int count;
} ZooData;

// Функция инициализации
ZooData create_zoo(int count) {
    ZooData zoo;
    zoo.count = count;
    zoo.x = (float*)malloc(count * sizeof(float));
    zoo.y = (float*)malloc(count * sizeof(float));
    zoo.types = (int*)malloc(count * sizeof(int));
    
    for (int i = 0; i < count; i++) {
        zoo.x[i] = (float)i;
        zoo.y[i] = (float)i;
        zoo.types[i] = i % 2;
    }
    return zoo;
}

// функция движения
void update_positions(ZooData* zoo) {
    // Процессор ликует: данные лежат в памяти ровной линией.
    // Пока он считает x[0], контроллер памяти уже подтягивает x[1...15] в кэш.
    for (int i = 0; i < zoo->count; i++) {
        zoo->x[i] += 1.0f;
        zoo->y[i] += 1.0f;
    }
}

int main() {
    int count = 100000;
    ZooData zoo = create_zoo(count);

    update_positions(&zoo);

    printf("Done! First animal pos: %f, %f\n", zoo.x[0], zoo.y[0]);
    return 0;
}
Скрытый текст

Лучше вместо цикла for использовать SIMD с каскадом масок, но это будет душно для объяснения

Если в примере с Python мы создавали лабиринт из ссылок, то в реализации на C мы построили хайвей для данных. Разница здесь не только в скорости самого языка, но и в фундаментальном подходе к памяти:

  1. Предсказуемость против Хаоса: В Python процессор никогда не знает, где окажется следующий «котенок». В C-реализации (SoA) данные лежат плотными рядами. Процессор видит это, активирует Hardware Prefetcher и начинает подгружать координаты в кэш еще до того, как цикл до них дойдет.

  2. Нулевой «налог» на абстракцию: В ООП-версии мы платили памятью за каждое имя, каждый метод и каждый тип объекта. В DOD-версии на C мы платим только за то, что используем. Если мы обновляем позиции — в кэш попадают только позиции.

  3. Дружба с кэш-линиями: В Python-версии одна кэш-линия (64 байта) зачастую не могла вместить даже одного целого «кота» со всеми его потрохами. В C-версии в ту же кэш-линию влетает 16 координат сразу. Это буквально означает, что процессор работает в 16 раз эффективнее с тем же объемом памяти.

Вывод

Главная проблема современного программирования не в Python, не в объектах и даже не в лени. Проблема в иллюзии, что железо — это абстрактный «черный ящик» с бесконечной мощностью, который обязан исполнять наши фантазии с любой скоростью.

Мы десятилетиями строили «Программирование для Людей», создавая удобные иерархии котиков и собачек. Но за это удобство мы заплатили чудовищным налогом — Data Inefficiency.

  1. Абстракции лгут: ООП учит нас, что объект — это единица реальности. Но для процессора объект — это мусор. Для процессора реальностью являются только потоки данных. Когда мы группируем данные по «смыслу» (все свойства кота вместе), мы идем против физики кремния. Железу плевать на смысл, ему важна ритмичность.

  2. Кризис сложности: Мы привыкли решать проблемы производительности, докупая ядра или гигабайты памяти. Но DOD показывает, что часто мы используем возможности текущего железа лишь на 1-5%. Мы строим небоскребы на гнилом фундаменте из «указательного ада» и удивляемся, почему всё тормозит.

  3. Возврат к истокам: Data-Oriented Design — это не шаг назад к низкоуровневому мучению. Это шаг к осознанности. Это признание того, что код и данные — не одно и то же. Код — это инструмент трансформации, а данные — это нефть. И чтобы эта нефть текла быстро, трубы должны быть прямыми, а не завязанными в узлы наследования.

Финальная мысль статьи: Я не призываю завтра же переписать всё на чистый С и массивы флоатов. Я призываю перестать прятать данные за поведением. В следующий раз, когда вы создадите класс-обертку для одного числа, спросите себя: «Я упрощаю жизнь себе или усложняю её процессору?». Потому что в конечном итоге, единственная реальность в нашей профессии — это то, как быстро биты бегут по шине памяти.

«Разница между хорошим и плохим дизайном заключается в том, насколько эффективно вы перемещаете данные через систему. Если вы тратите время на управление объектами вместо управления данными — вы уже проиграли».— Джон Кармак

Комментарии (11)


  1. bighorik
    05.02.2026 18:29

    Потому что в конечном итоге, единственная реальность в нашей профессии — это то, как быстро биты бегут по шине памяти.

    Реальность разработчиков ПО заключается в том, насколько дешево и качественно мы решаем проблемы бизнеса. Если стоимость ванбординга нового сотрудника или стоимость дополнительного времени разработчика, потраченного на чтение и вникание в подобный код, выше цены на сервер побольше, то писать код из вашего раздела с С не рентабельно.

    Опять таки, это с поправкой на бизнес потребность. Если у бизнеса есть потребность в 100500к рпс, подобные оптимизации становятся оправданнее


    1. SyncSay Автор
      05.02.2026 18:29

      да, но я говорю относительно собственных проектов


  1. kyznetsovsergei
    05.02.2026 18:29

    Довольно крутое пояснение. Так же хотел сказать про распределение задач на разные потоки и ядра. Да, кеш третьего уровня общий, но так же есть отдельный кеш на каждое ядро, первого и второго уровня. И таким образом нагрузку можно уменьшить путем распределения инструкций на разные потоки и ядра, особенно если данные не конфликтуют по кеш линиям.Так же думаю, что все зависит от компилятора, который указывает на дыры в оптимизации или сам оптимизирует код, например может переупорядочивать инструкции, векторизовать циклы и эффективнее работать с памятью. Но при этом, если сами данные организованы плохо, как в примере с ООП и разрозненными объектами, компилятор уже мало что спасет, потому что упрется в задержки памяти и cache miss.Как совет, думаю, что важно учитывать не только многопоточность, но и локальность данных, выравнивание структур, размер кеш линий и доступа к памяти. Тогда и проц, и prefetcher смогут работать максимально эффективно. В этом плане data oriented действительно может дать сильный прирост производительности, особенно в задачах с большими массивами однотипных данных


    1. SyncSay Автор
      05.02.2026 18:29

      да, спасибо что добавили! Забыл упомянуть


  1. SadOcean
    05.02.2026 18:29

    Все так, но вроде многие признают, что мы платим простотой организации за скорость.
    И, как показывает практика, готовы платить.
    Единственное стоит не забывать, что иногда платим дорого - ООП или какие то отдельные паттерны возводят в абсолют.

    Надо сказать, что абстрактное ООП в вакууме tool agnostic, оно непосредственно не накладывает ограничений ни на организацию данных в памяти, ни на производительность.
    Страдают в целом современные реализации.
    Тем не менее можно и лучше организовывать данные и в рамках классического ООП, и есть варианты более дружелюбных архитектур типа ECS, которая (если очень грубо прикладывать) есть агентное ООП с анемичными данными. Что плохо для читаемости, но лучше для скорости перекладывания байтов.


  1. cmyser
    05.02.2026 18:29

    Как в таком случае писать бизнес логику ?

    Преимущество классов в том что мы можем зашить в них бизнес логику и там же поддерживаем инварианты

    Отделяем правильное поведение от неправильного

    Как в вашем случае это сделать ?


  1. uxgen
    05.02.2026 18:29

    А если капнуть глубже, то современные ЦП способны читать из L1 в 2 регистра по 64байт выровненных данных за такт, но пока мы используем скаляры все это замедляется до 2х 4 байт за такт. И используем только одну инструкцию на скаляр вместо инструкции на 16х скаляров.


  1. Vsevolod_888
    05.02.2026 18:29

    Из статьи можно сделать только один вывод - питон плохо работает на процессоре(каком-то) а си работает хорошо. Мне, все же, кажется что дело в том, что си компилируется нормально и даже если написать на плюсах тот же ООП как в питоне, то через компилятор до проца дойдет вариант ничем не хуже чем на си. Привели бы, хотя бы, пример на питоне как хорошо, и на нем же - как плохо. А то между абстрактным кодом и тем что реально попадает в проц - слишком много вариантов чтобы строить теории как правильно


  1. GBR-613
    05.02.2026 18:29

    Если проект устроен так, что с кучей котов приходится делать что-то более или менее одинаковое одновременно, тогда действительно: заводить отдельный объект на каждого кота это порочный подход. Тогда нужно заводить объекты Катавасия и Псарня, в котором будут лежать массивы аттрибутов отдельных тварей. И, скажем, векторизация numpy будет управляться с ними очень быстро.

    Подход "каждому животному по объекту" нужен, когда Вы делаете совершенно разные вещи с каждым отдельным животным.

    Поэтому то, что Вы хотите сказать - это "не всегда самый напрашивающийся дизайн - самый лучший, и уж точно не всегда - самый оптимальный по производительности".


  1. ulovka22
    05.02.2026 18:29

    Даёшь ассемблер!


  1. Ravius
    05.02.2026 18:29

    Я не понял примера... я обычно для таких задач chatGPT прошу поменять значения. Делов то, зачем тут ваш Си? LLM как интерпретатор 10/10. Ну или хотя бы эксель.

    Ps на сколько я знаю компиляторы умеют такое оптимизировать. Но Змею обидеть может каждый.