Новосибирский Государственный Университет
Опубликован: 18.02.2011 | Доступ: свободный | Студентов: 938 / 287 | Оценка: 4.27 / 3.95 | Длительность: 04:10:00
Специальности: Программист
Лекция 5:

Векторизация

Аннотация: История возникновения и развития векторного расширения в массовых процессорах Intel. Способы использования векторных инструкций и их набор. Векторизация, осуществляемая явным образом при помощи вызова специфических инструкций и автоматическая векторизация при помощи компилятора Intel.

Сегодня мы попытаемся обсудить две достаточно большие темы: векторизацию и автопараллелизацию. Как мы уже обсуждали во время наших бесед, существует тенденция в развитии микропроцессоров, которая ведет нас к большему количеству различной параллельной работы. То есть конвейеризация, суперскалярность, векторные операции — это все какие-то дополнительные уровни параллелизма, когда мы одновременно пытаемся разбирать и обрабатывать несколько инструкций, одновременно пытаемся делать несколько логических и арифметических операций. По большому счету, векторизация — это тоже технология одновременного дополнительного выполнения инструкций. Ее обычно называют "параллелизм данных", "дата-параллелизм", и она подразумевает уход от последовательного выполнения скалярных к одновременному выполнению операций над множеством данных.

Последний элемент параллелизма, который мы сегодня попытаемся затронуть, — это многоядерные и многопроцессорные решения, и, соответственно, создание каких-то многопоточных приложений. И рассмотрим, чем может помочь компилятор в использовании 3-го и 4-го уровня параллелизма. Если мы будем говорить о компиляторе как инструменте, транслирующем исходный код в исполняемый модуль и оптимизирующий исходный код для получения лучшей производительности, то можно говорить, что параллелизация — это некая трансформация, которую выполняет компилятор; трансформация последовательно выполняющейся программы в программу, в которой набор инструкций выполняется одновременно и сохраняется смысл программы. То есть сохраняется то, что мы на прошлой лекции обсуждали как эквивалентность вычислений.

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

Типичная векторная инструкция представляет собой операцию над двумя векторами в памяти или в регистрах фиксированной длины. Данные векторные регистры могут быть загружены из памяти либо за одну операцию, либо по частям. Если мы рассматриваем компилятор, то векторизация — это компиляторная оптимизация, которая заменяет скалярный код на векторный. Оптимизация упаковывает данные в вектора и заменяет скалярные операции на операции с векторами (пакетами). То есть, если мы рассматриваем векторизацию как векторизацию, которую вы делаете руками, это то же самое. Только вы ту же самую векторизацию будете делать с помощью интринсиков или других специальных средств, которые там предоставляют языки программирования. Если вы будете читать какие-то книги, в том числе посвященные векторизации, то вы узнаете, что изначально векторизация распространялась в основном на фортрановских программах, потому что Фортран изначально конструировался как язык для выполнения вычислений, и в нем есть достаточно удобные элементы для описания векторизации.

Например, в Fortran есть такой конструктор, как секция массива. То есть, если у нас несколько элементов в массиве, которые либо находятся последовательно друг за другом, либо лежат с каким-то константным шагом. И вот через секцию массива Fortran очень легко описывается идея векторизации. Поэтому в разных книжках активно используются конструкции Fortran. С помощью секции массива векторизация может быть представлена следующим образом: изначально мы имели цикл, в левой части стояли какие-то элементы массива, в правой части стояли элементы массива, после векторизации мы получаем сокращение количества итераций и теперь уже мы оперируем не элементами массива, а секциями массива. В терминах секции массива в Fortran векторизация выглядит примерно вот так.

Остановимся на том, каким образом эти векторные инструкции развивались внутри интеловских процессоров. Понятно, что мы не будем затрагивать тему каких-то серверов, потому что изначально компьютеры были еще не так сильно распространены. Существовали различные группы ученых, которые создавали серьезные вычислительные машины, в которых как-то реализовывалась векторизация. Но мы будем рассматривать историю развития векторизации на базе процессоров Intel на базе IA Интел архитектура 32. Если посмотреть достаточно недавнюю историю развития процессоров, то там было такое устройство, которое называлась "сопроцессор". Когда-то сопроцессор и процессор были два разных устройства, они друг с другом обменивались по шине данных. При этом сопроцессор было устройство оптимизированное и созданное для работы с вещественными числами. Поэтому когда-то очень давно можно было купить вычислительную систему с сопроцессором, которая быстрее работает с вещественными числами. Либо без сопроцессора, и тогда работа с вещественными числами иммулировалась процессором через какое-то целое численное вычисление. И вот в какой-то момент времени (конкретно это было в 486 dxmp микропроцессоре) эти две микросхемы были объединены в одну. Можно сказать, что сопроцессор был интегрирован в процессор. В результате в процессоре добавилось 8 80-битных регистра для работы с вещественными числами. В какой-то момент времени возник вопрос: вот у нас есть 8 80-битных регистров: целая мощная функциональность внутри процессора, но когда мы работаем с численными вычислениями, у нас вот эта вещь простаивает. В результате возникло желание создать на базе этих вещественных регистров какие-то векторные регистры для работы с целочисленными вычислениями, для поддержки целочисленных вычислений. Возникла технология MMX — набор инструкций, выполняющих характерные для процессора кодирования/декодирования потоковых аудио/видео данных действия. Эта технология добавляла 8 64-битных регистра, которые олеасились с теми вещественными 80-битными регистрами, которые достались процессору при интеграции с сопроцессором. Соответственно в эти регистры можно было упаковывать целочисленные данные и выполнять над ними векторные операции. Это первая итерация в сторону векторного выполнения инструкций. То есть тут в ММХ технологии были существенные недостатки. Они были связаны с тем, что нельзя было одновременно использовать его для векторных работ с целыми и для работы с вещественными.

Следующая итерация – введение SSE, набора SSE поддержки на микропроцессоре, оно эту проблему решило. То есть это расширение процессора набора инструкций, позволяющее работать с множеством данных. SSE технология обогатила процессор восьмью 128-битными регистрами, которые называются xmm0 до xmm7 и набором инструкций, которые позволяют производить операции со скалярными и упакованными данными. Причем, как с целыми, так и с вещественными. Технология imm 64 T, то есть расширение адресов до 6 и 4 бит, которая была в какой-то момент внедрена, увеличила количество, в том числе, и вещественных регистров. То есть у нас на наших процессорах сейчас 16 128-битных регистров: от xmm0 до xmm15. после этого было сделано еще несколько различных расширений процессора: SSE2, SSE3, SSE4,. Например, SSE2 добавил тип упакованных данных с плавающей точкой двойной точности. Cейчас уже выходит архитектура санди бридж, в сандибридже уже следующая итерация этой идеи. Называется это новое расширение Edvanced Vector Extentien. Это новое расширение предоставляет различные улучшения, новую инструкцию и новые схемы кодирования машинных кодов. И вот в этом расширении размер векторных регистров еще увеличен с 128 до 256 бит, и эти регистры, соответственно, уже называются YMM0-YMM15.

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

Формально понятно, что существуют различные типы группы векторных инструкций, мы не будем подробно их рассматривать. Я привел их здесь только для того, чтобы вас познакомить с различными группами инструкций векторных. Первая – это инструкция перемещения данных. Здесь можно обратить внимание на то, что часто здесь слова выровненные, невыровненные и т.д. Потом здесь есть группа целочисленных арифметических инструкций, арифметические операции с вещественными числами, идиоматические и арифметические инструкции, логические инструкции, инструкции сравнения, конвертирующие инструкции, инструкции сдвига, перестановочные инструкции, распаковывающие инструкции, инструкции работы с кэшем, на которых мы, возможно, остановимся, и управляющие состоянием инструкции. Векторизация ввела все эти наборы инструкций. Расширения, по сути, вводятся для того, чтобы улучшить производительность процессора. Нужно подразумевать, что каждое последующее расширение, наверное, лучше, чем предыдущее и в плане производительности, и в плане набора каких-то специфических инструкций, которые могут вам помочь создавать более производительный код. Важно становится передать компилятору информацию о том, на каком процессоре вы хотите, чтобы выполнялась ваша программа. В этом случае процессор может подготовить программу, которая будет работать оптимально с точки зрения производительности. И это удобное время, чтобы познакомиться с тем, какие есть конкретно ключики у компилятора, чтобы эту информацию передать.

Есть три разных набора опций:

– Qx<EXT>, например, – QxSSE4_1. То есть вот с этим набором опций в программу включается проверка процессора. Если при проверке процессора, на котором эта программа запускается, выясняется, что это процессор не тот, для которого компилятор создавал этот исполняемый модуль, то будет выдана ошибка. Второй вариант — это опция arch:<EXT>, например, –arch:SSE3. В этом случае проверки возможен интересный эффект. Если вы создали исполняемый модуль какой-то, а в нем из-за того, что векторизация была консервативной, нет инструкции для того расширения, которое вы декларировали, а есть инструкции, которые поддерживают расширения нижнего уровня, с которыми эта программа будет работать. А если вдруг она не будет работать на процессоре, на котором вы запустите, в программе стоит инструкция, которую данный процессор не поддерживает, то будет падение программы при выполнении специфической процессорной инструкции.

И последний, третий, путь — это создание многоверсионной исполняемой программы с помощью опций QAX. Здесь вы можете через запятую указать несколько различных векторных расширений. Тогда у вас будет создана программа, которая будет содержать многоверсионный код. Она будет большая по размерам; в момент, когда вы ее запустите на каком-то процессоре, она будет искать ту версию кода, которая для данного процессора более оптимальна. Intel все эти вещи определения процессора поддерживает только для интеловских процессоров. Для неинтеловского процессора будет использоваться умолчательный код.

То есть понятно, что если вы хотите более подробно познакомиться с SSE технологией, то существует абсолютно доступная в открытом использовании документация. Это, например, 10 глава замечательной книги CHAPTER 10 PROGRAMMING WITH STREAMING SIMD EXTENSIONS (SSE) документа "Intel 64 IA-32 Intel Architecture Software Developer's Manual".

Следующий шаг. Visual Studio поддерживает набор SSE инструкций, которые позволяют напрямую использовать SSE инструкции из С/C++ кода. Для этого в языках есть специальные библиотеки, которые позволяют на более высоком уровне использовать векторизацию. В этом смысле удобнее использовать SSE интринсики. То есть для того, чтобы использовать этот набор интринсиков, достаточно подключить отрицательный файл xmm intrin.h, этот описательный файл определяет тип_m128 (это векторный тип) и операции с ним. То есть, если мы хотим факторизовать вручную такой цикл, то нам для этого необходимо организовать и заполнить векторные переменные, запустить интринсик для умножения векторных переменных, скопировать результаты вычислений в память. Я использовал наш компилятор версии 12.0 и вот какую-то вот такую программу получил.

Вот, в принципе, самая короткая программа, которую я смог придумать. Она перемножает матрицы. И опять же, под макросом при компиляции передается снаружи, я вставил вот такой вот код. По умолчанию, если перф не передается при компиляции, стоит обычный код. Вот у меня получилась программа. Я ее компилирую с опции –Od, чтобы компилятору запретить делать какие-либо оптимизации. И в первом случае я просто использовал опцию Od, А во втором я передаю опции - D перф, чтобы получить тот код, который я векторизовал руками. После этого я запускаю эти две программки и оцениваю время выполнения этих двух программ. То есть в скалярном варианте это программка считалась 3,5 сек., в варианте, векторизованном руками – 1,2 сек. Даже без использования каких-либо компиляторных оптимизаций векторизация такой программки дала существенный прирост производительности. Прирост производительности 2,7 раза. Но тут на самом деле это все нечестно (то, что я здесь привел), и в принципе этот пример хорош только тем, что он реально простой, самый минимум изменений. Реально для работы некоторых инструкций необходимо, чтобы адреса в памяти были выровнены на 16, в моем тесте это получилось случайно. Если бы я вот здесь, где у меня задается размеры массива указал m не 40, а какое-нибудь 39, то вполне вероятно, что программа моя бы сломалась из-за того, что выполняет неверно какие-то действия.

Ну и вот еще в случае со скалярным кодом, если мы будем использовать компилятор с векторизацией, то мы увидим, что время выполнения программы существенно ниже. Даже того, которое мы сделали векторизацию руками. Ну и опять же встает вопрос о допустимости векторизации, то есть векторизация это такая же формально перестановочная оптимизация. Почему она перестановочная, то есть изначально мы работали со скалярными вычислениями, которые выполнялись последовательно. После векторизации мы несколько вычислений, которые выполнялись последовательно, выполняем одновременно. Это есть некий элемент перестановочной оптимизации. Поэтому для того, чтобы сказать, допустима векторизация или нет, мы должны вспомнить все то, что говорили на прошлом занятии. Про то, что существуют некие зависимые инструкции. Для того, чтобы добиться эквивалентности вычислений, мы не можем переставлять местами зависимые друг от друга инструкции. Векторизация допустима когда? Ну самый простейший случай, когда зависимостей в векторизуемом цикле нет. Если зависимости в векторизованном цикле есть, то их порядок после векторизации должен совпадать с из порядком до векторизации.

Когда компилятор делает векторизацию, для удобства пользователей у него есть специальная диагностическая опция Qvec-report, которая позволяет получать различную диагностику, связанную с векторизацией. Она с определенным ключом может вам показать те циклы вашей программы, которые были векторизованы, либо может показать те циклы, которые по каким-то причинам не были векторизованы, и указать эту причину. Существуют вот такие примеры диагностики циклов: цикл был векторизован, либо цикл не был векторизован, потому что векторизация возможна, но не выглядит эффективной. Или цикл не был векторизован, потому что цикл не стандартный, не является кандидатом на векторизацию. Есть различные диагностики.

Возвращаясь к допустимости векторизации. Есть некий наглядный критерий возможности векторизации, выраженный через использование фортрановских секций массива, то есть если введение секций массива не порождает зависимостей, то скорей всего цикл может быть векторизован. Здесь приведено 2 примера. 1-й пример: мы цикл перезаписываем в терминах секций – в левой и в правой части получается одна и та же секция, поэтому никакой зависимости не возникает. А во втором случае, если мы выразим это все в виде секции, то мы увидим, что эти секции на последующих уровнях итерации будут перекрываться. При использовании секций зависимости возникают. Из этих соображений можно сказать, что цикл не может быть векторизован.

Можно выдвинуть очевидное предположение: цикл можно векторизовать, если дистанция для зависимости >=К, где К – количество элементов массива, входящих в векторный регистр. То есть пишем маленькую программку, чтобы проверить этот факт, запускаем на компилятор и действительно видим, что дистанция между зависимостей определяется с помощью параметра Р, который либо равен 4, либо 3. Если дистанция между зависимостей 3, наше предположение не выполняется, и цикл не векторизуется. Если дистанция 4, это как раз та дистанция, то количество элементов, которое входит в векторный регистр, в этом случае все успешно векторизуется. То есть в случае с векторизацией интересна дистанция между зависимостями.

Теперь возникает вопрос: а реально всегда ли выгодна векторизация? И вообще, какие критерии определяют прирост производительности при векторизации? Для того, чтобы это оценить, я написал маленькую программку. Здесь используется Shift, который можно при компиляции варьировать. Мы варьируем при компиляции Shift, устанавливаем его в одном случае 0, в другом 1. И получаем, что в этом случае такая программка показывает разницу в производительности в 2 раза. Из-за чего это может быть? Мы посмотрим на ассемблер и сравним между собой ассемблер для одной программы и ассемблер для второй программы. Мы увидим, что в одном случае у нас везде стоят инструкции MOVDQA, который расшифровывается как Move Aligned Double Quadword, а вторая Move Unaligned Quadword. И, видимо, в данной ситуации разница в производительности происходит из-за того, что в одном случае мы работаем с некими выровненными данными, в другом случае мы работаем с некими невыровненными данными. Отсюда вывод, что производительность векторизованного цикла зависит от того, каким образом векторизуемые объекты расположены в памяти.

Огромный аспект, имеющий значение для производительности программы — это выравнивание данных в памяти. Из-за чего это происходит? Наши векторные регистры мы можем заполнять за одну операцию, копируя блок памяти. Это копирование разрешено, если этот блок памяти, который мы хотим скопировать в векторный регистр, выровнен на 16. Если он на 16 не выровнен, мы используем другую операцию, которая этот регистр заполняет другими методами по частям. И разница в производительности отсюда и происходит. То есть копирование выровненного адреса получается гораздо более эффективным, происходит гораздо быстрее. Отсюда возникает проблема, что векторизация тем быстрее и эффективнее происходит, чем лучше объекты, с которыми мы будем работать, расположены в памяти. Каким образом различные объекты, с которыми мы работаем в программе, лежат вот в этой памяти? Здесь существуют некие правила, которые определяют метод, с помощью которого данные располагаются и доступаются в памяти. Это понятие заключает в себя 2 различных, но взаимосвязанных вопроса: выравнивание данных и заполнение структур данных. Выравнивание данных определяет, как те или иные данные расположены относительно границ памяти. Этот факт, как правило, связан с типом данных. Заполнение структур данных подразумевает вставку безымянных полей в структуру данных для того, чтобы сохранить относительное выравнивание полей структуры. Если вы работаете со структурами, выясняется, что внутрь этой структуры вставляются какие-то промежутки для того, чтобы выполнить некие требования по выравниванию типов данных в памяти.

Информация о выравнивании может быть получена с помощью интринсика _alignof_. Размер переменной данного типа и выравнивание по умолчанию может зависеть от типа компилятора. Выравнивание для 32-битного интеловского компилятора различных типов будет выглядеть вот так. Если мы возьмем 64-битный компилятор, там будут несколько другие цифры. Те же самые правила используются для выравнивания массивов. Помимо этого существует возможность потребовать, чтобы объект был выровнен определенным образом. То есть задается команда decklspec элайн 16 и мы хотим, чтобы наш вещественный массив был выровнен на границе 16. Вот пример того, как будет выглядеть вот такая структура благодаря выравниванию объекта по ней. То есть вы видите, что на самом деле внутрь вашей структуры навставлялись какие-то специальные поля, промежутки. И интересно, что меняя порядок различных ваших типов в структуре, вы можете существенно повлиять на размер этой структуры. Размер структуры не будет равен сумме тех полей, которые вы в этой структуре описали. Если мы будем размещать поля в объекте в порядке уменьшения, то у нас в результате получится минимальная по размеру структура. Это тоже может каким-то образом влиять на производительность. Внутри вашей структуры вы можете использовать decklspec, чтобы даны выравнивать так, как вы хотите.

Из-за того, что возникает проблема с выравниванием, векторизация простого цикла получается несколько сложнее, чем то, что я вам показал в примере, где использовались интринсики. В левой части стоит некий массив, в который что-то присваивается. Для того, чтобы сделать векторизацию оптимальной, мы делаем сначала небольшой прицикл, где с помощью нескольких итераций мы достигаем того момента, когда элементы массива становятся выровнены на 16. Получается, что при векторизации мы получаем из 1 цикла 3: 1 векторный и 2 скалярных.

Поскольку вот такой вот метод существует, то возникает вопрос, а что будет происходить, если у вас в правой части вашего присваивания будут еще какие-то массивные элементы. Получается, что векторизация в том виде, который делает компилятор, ориентирована на какой-то один цикл. Например, тот цикл, который стоит в левой части. Не на один цикл, а на один массив. Тот, который стоит в левой части, а те массивы, которые стоят в правой части… ну как будет компилятор разбираться, выровнены они или нет. То есть для левого массива он взял и несколько приитераций сделал и дошел до адреса, где он выровнен. А что будет происходить для тех массивов, которые в правой части присваивания стоят? Он с ними как-то специально не работает, поэтому, скорее всего, он будет считать, что они не выровнены в памяти. Если мы с вами такой пример разберем, потом посмотрим на ассемблер, то мы убедимся, что для массивов в правой части могут быть использованы инструкции для доступа к невыровненной памяти. Но иногда это можно победить, предупредив компилятор о том, что наши массивы, наши аргументы, поступившие внутрь функции, выровнены на 16. Это можно сделать, поставив инструкцию вот такого вида _assume_aligned(a16). Ну, и можно добиться того, что при всех равных условиях, если вы следите, передавая аргументы, чтобы они были выровнены по памяти, чтобы у вас внутри этого цикла происходила векторизация, которая будет использовать инструкции обращения к выровненной памяти.

В данном случае тот же самый пример: я добавил в нашу функцию следующие строки: _assume_aligned (a,16); _assume_aligned (b,16); _assume_aligned (b,16) и получил код без головного выравнивания цикла, использующий инструкции movaps для доступа к памяти. Оценим выгодность полученного кода, добавив к нашей функции. Вы видите, что здесь разница не очень большая. Это связано с тем, что всегда существует самая главная проблема и проблема менее главная. Вот, например, работа с памятью она для производительности приложений играет самую важную роль, а использование выровненных инструкций — это уже вторично. Если вы имеете проблемы и не успеваете все адреса подкачать в кэш при выполнении какой-то программы, то у вас, скорее всего, влияние использования инструкций для работы с выровненной и невыровненной памятью будут иллюминироваться, уже не будут так заметны.

Отсюда можно сделать вывод, что векторизация будет неэффективна для каких-то маленьких циклов. Пример показывает, что без векторизации цикл выполняется чуть быстрее, чем с векторизацией. Потому что вот этот вот цикл, здесь количество итераций =11. В результате цикл распадается на 3 цикла, каждый из которых очень маленький.

В общем случае векторизация в нашем компиляторе делается на самом внутреннем цикле. Существуют случаи, когда на внутреннем цикле векторизацию нельзя сделать из-за наличия зависимостей, и можно сделать векторизацию на каком-то внешнем цикле. Здесь приведен пример. Но вот вопрос…а будет ли полезна такая оптимизация? Векторизация внешнего цикла вряд ли будет выглядеть выгодной, но возможно существуют какие-то случаи. Например, есть какие-то очень медленные операции SQRT например, операция извлечения корня может быть медленная и 8 скалярных и 1 векторная операция окупит 8 скалярных. Мы, делая векторизацию внешнего цикла, вынуждены будем векторные регистры заполнять по частям. И от этого большая часть пользы от векторизации просто исчезнет.

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

Существуют какие-то полезные способы передать дополнительную информацию компилятору, передать свои пожелания о векторизации. Например, компилятор не может разобраться и говорит, что я не могу этот цикл векторизировать, потому что, по моему мнению, здесь существует некая зависимость. А вы, как многомудрые, посмотрели и говорите, что здесь зависимости нет, что игнорируйте зависимость, делайте векторизацию. То же самое следующая прагма определяет как векторизовать цикл. Вы так же можете поставить прагму и запретить векторизацию. Существуют соответствующие аналоги для языка Фортран. Интересно то, что многие оптимизации, в том числе векторизация, тесно пересекаются со всякими другими оптимизациями. Поэтому для того, чтобы достичь лучшей производительности, компилятор напрягается и пытается сначала получить доступ к памяти, потом еще какие-то вещи сделать: объединить циклы, после этого сделать векторизацию, то есть все оптимизации очень сильно взаимосвязаны. Векторизация идет в контексте каких-то других цикловых оптимизаций.