Изображение — betterprogramming
Изображение — betterprogramming

В блоге beeline cloud мы делились историями и мнениями разработчиков — как программист-самоучка выучил 30 языков программирования, в каких случаях парное программирование не работает и почему некоторые проекты угасают, когда из компании уходит тимлид разработки. Сегодня поговорим о том, как изучать азы информатики при помощи Stack Overflow, даже если ваше образование не связано с компьютерными технологиями. Вот интересный перевод.

Мое основное образование далеко от ИТ. Но приблизительно в 2016 году я придумал, как изучать основы информатики при помощи Stack Overflow. Так у меня появилось увлекательное хобби. В свободное время я проглядываю сайт в поисках вопросов, получивших наибольшее количество голосов.

Сам метод, а также результаты, которые он приносит, я описал в своей статье 16-часовая тренировка для разработчиков. В чем же основные его преимущества? В том, что такой подход зачастую намного лучше, чем чтение учебников во время обучения в ВУЗе. 

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

Одному из вопросов, набравшему наибольшее количество голосов на Stack Overflow (сейчас это порядка 24 миллионов «плюсов»!), я хочу посвятить эту статью.

Первое знакомство

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

Все ясно, подумал я, — очередной вопрос со звездочкой, типичный для интервью в FAAMG.

Ничего нового. Преимущества поиска в отсортированном массиве широко известны. А знаменитый двоичный поиск и вовсе превратился в притчу во языцех на собеседованиях по программированию.

Но посмотрев два небольших сниппета кода, доступных по ссылке, я озадачился всерьез. Сам код (циклы на C++ и Java) был довольно простым. Такой я мог бы написать в колледже даже без помощи преподавателя по программированию.

// !!! With this, the next loop runs faster.
   std::sort(data, data + arraySize);

   // Test
   clock_t start = clock();
   long long sum = 0;
   for (unsigned i = 0; i < 100000; ++i)
   {
       for (unsigned c = 0; c < arraySize; ++c)
       {   // Primary loop.
           if (data[c] >= 128)
               sum += data[c];
       }
   }

Поэтому с восприятием кода у меня сложностей не возникло. Но, тем не менее, сам код не вписывался ни в одну из привычных для меня моделей.

Пришлось прочесть его трижды, подавляя в себе желание прокрутить страницу, чтобы узнать ответ. Но что‑то все равно ускользало от моего понимания. Что это за обработка массива такая? Перебор элемент за элементом? Очень похоже на обычный поиск, и при этом не самый быстрый.

Лишь через какое‑то время я понял, что вижу совсем иной вид поиска, и уж точно не двоичный. Каждый элемент здесь обрабатывался особым O(N) порядком. Циклы не зависели от математических предположений программиста, таких как:

if value < maxElement/2
  search for value in (0...maxElement/2 - 1)
else
  search for value in (maxElement/2...maxElement)

Что же позволило ускорить обработку отсортированного массива?

Краткий ответ на вопрос

В данном случае весь фокус не в программе, а в машине, которая ее выполняет. Рейтинговый вопрос со StackOverflow в большей степени касается не программной части, а аппаратных возможностей платформы.

Лучшим ответом на него было сравнение с железнодорожным узлом. Прежде чем читать дальше, запомните основы этой аналогии:

Узел = инструкция сравнения (оператор If)

Ветвь = результат выполнения инструкции JUMP или ее разновидности

(поток управления if-else/while)

Изображение — StackOverflow
Изображение — StackOverflow

Представьте себе незрячего стрелочника начала 1800-х годов. В обычном случае ему пришлось бы остановить поезд на развилке. Затем спросить машиниста по какой ветке ему ехать дальше. После получения нужной информации он переключал стрелку.

Способ крайне неэффективный, учитывая потерю времени и топлива. Для его оптимизации у такого оператора был единственный выбор. Повернуть стрелку, руководствуясь собственными предположениями, до того, как поезд прибудет к развилке.

  • Если бы он был оракулом, поезду никогда бы не пришлось останавливаться.

  • Но если пророчества дадут осечку, машинисту придется прекратить движение и дать задний ход.

Однако, если графики движения предсказуемы, у этой модели будут большие шансы на успех (первый вариант). Теперь рассмотрим этот сценарий в реальном программировании.

При обработке массива, содержащего сравнение (if currentElement <= or == or ≥ value), процессор использует хитрый трюк.

Допустим, цикл выполняется N раз. На каждой k-итерации (0 < k < N-1) обработки массива он будет пытаться «вспомнить», что происходило в прошлых итерациях от 0 до k-1. Использование данных зависит от сложности устройства процессора, поэтому пока оставим это в стороне.

Если условие (например, currentElement == value) было истинным в большинстве из k-1 раз, предполагается, что оно будет истинным и в k-n раз. И наоборот.

Исходя из того, что значения счетчика определенно будут меньше 30 (на основании предыдущих, таких как 17, 18, 19...), зачем дожидаться 20 (или 21, 22 или 23)?

Нужно просто руководствоваться тем, что они меньше 30.

Что же все-таки из себя представляет Branch predictor — модуль предсказания переходов (прогнозирования ветвлений)

И если он настолько хорош, то что с ним до сих пор не так?

«Бутылочное горлышко» фон Неймана

Процессор выполняет инструкции. Под этими словами подразумеваются все совершаемые им вычисления (c = a + b) и сравнения (i == 100). Но это лишь малая часть его работы. Тем не менее, именно о ней мы сейчас поговорим.

К сведению: для исполнения каждой инструкции (на языке ассемблера) процессор также решает и другие связанные задачи:

  • Выборка: извлекаются инструкции и операнды (a, b, c, +, = или процессорный эквивалент инструкции IF).

  • Декодирование: преобразование адресов памяти соответствующих пространств, поскольку ЦП выполняет несколько программ в множестве контекстов.

  • Выполнение: знакомо нам как оператор языка программирования (например, c = a + b).

  • Обратная запись: изменение памяти данных/программ полученным результатом, например, заполнение переменной C значением A + B.

Объединение этих четырех этапов известно как 4-этапный цикл команд процессора.

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

Другими словами, выполнение одной ассемблерной команды занимает не менее чем N (>1) тактовых циклов процессора, где N представляет собой длину конвейера команд.

Разумеется, это всего лишь крайне упрощенное объяснение. Диаграмма 4-этапного конвейера команд (N=4) приведена ниже.

4-этапный конвейер команд  — Wikipedia
4-этапный конвейер команд — Wikipedia

Так выглядит 5-этапный (N=5) вариант конвейера команд:

5-этапный цикл инструкций — Wikipedia
5-этапный цикл инструкций — Wikipedia

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

Чтобы хоть как‑то снизить остроту проблемы, разработчики компьютеров придумали множество стратегий.

Почти все из них нам знакомы:

  • Кэш процессора

  • Разделение кэша между памятью данных и памятью программ

  • Система на чипе — линейка продуктов M1 компании Apple является ее потомком

За исключением модуля предсказания переходов (branch predictor).

Как работает предсказатель переходов

Предсказатель переходов — вероятностная стратегия, направленная на уменьшение количества пропущенных циклов процессора из‑за их повторяющегося характера.

Модуль реализован на аппаратном уровне. По сути он представляет собой цифровую схему, которую проще всего реализовать с помощью триггера.

В памяти предиктора сохраняется история выбора пути выполнения во время итерации внутри цикла for/ while.

Чем сложнее предсказатель переходов, тем больше информации о предыдущих ветвлениях способен он хранить в памяти. Соответственно тем точнее он может предсказать, какую ветвь следует выбрать для выполнения.

В цикле, содержащем 100 итераций внутри отсортированного массива, путь IF используется первые 51 раз, а путь ELSE — последние 49 раз.

Поскольку массив отсортирован, предсказателю ветвей нетрудно справится с задачей. Первые 51 раз выбор инструкций для пути IF будет происходить по следующей схеме: 1-й раз случайно, а со 2-го по 51-й раз на основе накопленной истории.

Каждый раз при выполнении оператора IF результат TRUE приведет к фазе EXECUTE пути IF. Переход будет осуществлен без ожидания фаз FETCH и DECODE (инструкций ветвления IF).

Так будет продолжаться до 51-й итерации, то есть до тех пор, пока предварительно выбранный набор инструкций предиктора ветвлений не совпадет с оценкой оператора IF. Другими словами, предсказатель перехода будет оперировать готовыми исполняемыми инструкциями ещё до того, как ЦП узнает, что IF выдал TRUE.

При выполнении 52-й итерации результатом IF будет FALSE. Процессор будет знать, что его предварительно выбранному набору инструкций необходима корректировка. Соответственно будет потрачено время на выборку и декодирование набора инструкций ELSE. Затем он продолжит работу с этим набором (выполняя EXECUTE без FETCH и DECODE) вплоть до 100-й итерации.

Начиная с 52-й итерации, процессор входит в повторяющийся режим работы. Можно провести аналогию с описанной выше железнодорожной ситуацией. Машинист поезда осознает ошибку незрячего стрелочника, возвращает состав к развилке и далее следует по правильному пути, не забыв о проникновенных пожеланиях в адрес несостоявшегося оракула.

При обработке отсортированного массива наказание не слишком суровое, учитывая уже полученную выгоду. В 98 случаях из 100 (за исключением 1-й и 52-й итераций) предварительная выборка инструкций все‑таки была результативной.

Но это простой пример. Циклы могут быть очень сложными. Хотя и разработчики аппаратных решений не дремлют. Активно развиваются продвинутые предсказатели переходов.

Для лучшего восприятия ниже приведена блок‑схема, взятая из технического блога. Ее логика ничем не отличается от того, как работает кэш.

Поток предсказателя переходов — The Beard Sage
Поток предсказателя переходов — The Beard Sage

Сложные предсказатели переходов можно построить и на программном уровне, размещая поток выполнения непосредственно в структуре данных. Об этом вы можете прочесть в блоге по ссылке (под диаграммой).

Заключение

Что же все-таки означает высокий рейтинг вопросов или ответов на Stack Overflow? И чем объяснить огромную популярность этого онлайн-ресурса?

В настоящее время Chat GPT создал серьезную альтернативу классическим форумам. В этих условиях рейтинг оценки Stack Overflow можно назвать несколько спорным. Он по-прежнему останется онлайн-свидетельством солидарности сообщества разработчиков, но не их компетенции в программировании.

Эффективность приобретения знаний очевидна, когда смежные области обсуждаются великими учеными и опытными отраслевыми экспертами. Вопрос о более быстрой обработке отсортированного массива наглядно это доказывает. Ответы на него охватывают не только то, как выполняются программы, но и то, как проектируются платформы. Становится понятным почему закон Мура не позволяет достичь желаемой вычислительной мощности, несмотря на увеличение скорости процессоров.

Ответы на вопросы, глубоко проникающие в суть концепций, как раз и создают вокруг ресурса StackOverflow его неповторимую ауру. Ни один инструмент искусственного интеллекта GenAI не может сравниться с историями и логическими рассуждениями, которые эксперты-добровольцы сами привносят в сообщество.

И особого уважения заслуживают любопытные и вдумчивые разработчики, которые своими вопросами провоцируют познавательные дискуссии о запутанной архитектуре алгоритмов!

beeline cloud — secure cloud provider. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.

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


  1. DenSigma
    28.03.2024 10:44

    Интересно, имеется ли этот эффект в Java, надо бы проверить.


    1. alexejisma
      28.03.2024 10:44

      При исполнении байт кода у JVM есть два сценария: 1. интерпретировать байт код; 2. компилировать байт код. В первом случае, как мне кажется, накладные расходы на интерпретацию будут слишком высоки и прироста в скорости работы заметить не получится. Во втором же случае происходит ровно тоже, что и в случае С++, поэтому ускорение должно наблюдаться.

      Где-то я читал, что начиная с какой-то версии JVM всегда компилирует байт код. Мог не так понять. В любом случае, желательно код перед бенчмарками "прогреть", то есть выполнить многократный вызов функции с кодом, чтобы вызвать срабатывание механизма компиляции.

      Еще, на сколько мне известно, JVM может обнаружить, что один и тот же код выполняется в цикле и оптимзировать это. Поэтому, возможно, придется как-то аккуратно запускать бенчмарк.


  1. Kelbon
    28.03.2024 10:44
    +5

    Тут интереснее другое, по сути компилятор обязан

    • увидеть, что data / array size не меняются в цикле

    • т.к. переполнение невозможно (UB) догадаться преобразовать это в выполнение цикла один раз и умножение на количество выполнений цикла выше (100'000)

    • в идеальном сценарии, если бы компилятор знал инвариант функции sort, он должен был бы найти один раз число >= 128 и от него до конца складывать числа, т.е. преобразовать цикл

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

    Но интересно, что первое он тоже не делает. Я вынес в функцию и сделал её pure - и это тоже не помогло, потому что он заинлайнил раньше чем понял в чём дело. Тогда я поставил noinline и действительно, только тогда он смог преобразовать убрав внешний цикл

    foo(int*, int):                              # @foo(int*, int)
            push    rax
            call    sum_foo(int*, int)
            imul    rax, rax, 100000
            pop     rcx
            ret
    .LCPI1_0:

    https://godbolt.org/z/hcE8o4vxK


    1. eptr
      28.03.2024 10:44

      Тут интереснее другое, по сути компилятор обязан

      Всё-таки, это — вторично, хотя и весьма полезно.
      А первично — то, что умножает основной посыл статьи на 0.

      Не буду расшифровывать в ассемблерном коде по вашей ссылке векторизованную часть цикла с MMX-инструкциями, которая обрабатывает за один проход цикла сразу 4 элемента, а возьму вместо этого концовку цикла с обычными инструкциями, которая обрабатывает последние 1-3 элемента, если размер массива не кратен 4:

              xor     edx, edx
              ...
      .LBB1_8: # =>This Inner Loop Header: Depth=1
              mov     r8d, dword ptr [rdi + 4*rsi]
              cmp     r8d, 127
              cmovle  r8d, edx
              add     rax, r8
              inc     rsi
              cmp     rcx, rsi
              jne     .LBB1_8

      Видите условный переход после сравнения r8d со 127?
      Вот и я не вижу.

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

      Таким образом, вы доказали своим кодом, что основной посыл статьи умножен на 0: нет условного перехода — нет и эффекта от предсказателя перехода.

      Справедливости ради следует заметить, что компилятор от Microsoft оптимизацию, на которую я намекаю, не выполняет.


  1. DenSigma
    28.03.2024 10:44

    Да, в Java такой эффект тоже есть.

    Benchmark      Mode  Cnt     Score     Error  Units
    App.sorted    thrpt   25  1979,922 ± 110,953  ops/s
    App.unsorted  thrpt   25   269,936 ±   9,185  ops/s