Hyper-Threading

Автор:

  • Янив Пессач

Как задействовать в приложении мощь новой технологии

Технология Hyper-Threading (поддержка логических процессоров физическим процессором) повышает эффективность процессора, позволяя выполнять два потока инструкций параллельно. Эта возможность появилась в сравнительно новых процессорах Intel Pentium 4; она позволяет увеличить производительность некоторых приложений на 20-30%, в отдельных случаях до 40%.

К сожалению, производительность остальных приложений не повышается, а иногда и уменьшается (мне попадались приложения, производительность которых снижалась на 20%), особенно если нарушаются рекомендации, такие как обсуждаемые в этой статье. Более того, вместе с преимуществами, получаемыми на однопроцессорных компьютерах с поддержкой Hyper-Threading, проявляют себя и некоторые ошибки в приложениях, которые ранее могли возникать только в многопроцессорных системах.

В этой статье я исследую технологию Hyper-Threading и продемонстрирую, как дополнить свой код поддержкой параллелизма, способной помочь увеличить производительность на компьютерах с Hyper-Threading. Я расскажу о тонкостях оптимизации для Hyper-Threading и покажу несколько полезных примеров. Мои примеры написаны на C#, но основные принципы применимы как к управляемым, так и к неуправляемым приложениям.

Важность Hyper-Threading

Технология Hyper-Threading доступна на сравнительно новых процессорах Pentium 4, обычно с тактовой частотой от 2,4 ГГц, а также на всех процессорах Xeon от 2,2 ГГц.

До появления Hyper-Threading процессор Pentium 4 мог исполнять лишь один поток инструкций одновременно. Процессор поддерживал единственный указатель команд (instruction pointer, IP), и вся многозадачность на однопроцессорных компьютерах базировалась на способности операционной системы распределять процессорное время между потоками. Hyper-Threading позволяет Pentium 4 выполнять два потока инструкций одновременно; процессор поддерживает два указателя команд и состояний машины (machine states) и незаметно для пользовательского кода переключается между ними.

Для выполнения машинной инструкции программы нужен один или более слотов исполнения (execution slots). Слот исполнения соответствует внутреннему ресурсу процессора, например блоку вычислений с плавающей точкой (floating point unit, FPU), и отражает способность процессора выполнить какую-либо операцию, скажем, вычисление значения с плавающей точкой.

Процессор Pentium 4 всегда знает, какие слоты исполнения доступны. Такая функциональность, как Out-Of-Order  Execution (когда процессор пытается выполнять инструкции в порядке, отличном от того, в котором они появляются в потоке инструкций), повышает производительность, так как это позволяет обеспечить работой больше слотов исполнения, а не ждать, пока какой-то слот освободится. В результате того, что процессор получил свободу переключения между двумя потоками, можно задействовать еще больше слотов исполнения. Пока один поток инструкций ожидает доступа к блокированному участку оперативной памяти или занятому слоту исполнения, процессор может обрабатывать команды из второго потока инструкций.

В процессорах с поддержкой Hyper-Threading два потока инструкций выполняются одновременно. Точнее, процессор выполняет несколько команд из первого потока, потом несколько команд из второго и т. д. Переключение между ними определяется алгоритмом, который пытается оптимизировать нагрузку между слотами исполнения и свести к минимуму возможность простоев; этот алгоритм также стремится равномерно распределять ресурсы между потоками инструкций. Контекст выполнения (например значения регистров) хранится в потоке инструкций. В итоге один процессор с поддержкой Hyper-Threading эмулирует два физических процессора, каждый из которых на самом деле является логическим.

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

Помимо общих ресурсов вроде FPU, модуля арифметической логики (ALU), разделяется и пространство внутри кэша процессора (оно делится на кэши уровней 1 и 2, также называемые L1 и L2), где кэшируются недавно использовавшиеся области оперативной памяти. Совместное использование пространства кэша может и повышать, и снижать производительность, но об этом я расскажу позже.

В процессорах с поддержкой Hyper-Threading два потока инструкций выполняются одновременно. Точнее, процессор выполняет несколько команд из первого потока, потом несколько команд из второго и т. д.

Если второго потока инструкций нет, тогда все пространство кэша и другие ресурсы передаются первому потоку. Поскольку многопоточный режим выполнения инструкций приводит к некоторым издержкам, процессор использует его, только если инструкции запланированы к выполнению на обоих логических процессорах. Кроме того, компьютер с одним процессором, но с включенной поддержкой Hyper-Threading использует многопроцессорную версию ядра операционной системы, которое обеспечивает более качественную синхронизацию, но за счет небольшой потери производительности.

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

Hyper-Threading в Windows

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

В Windows NT и более поздних версиях Windows планировщик поддерживает Hyper-Threading. Когда несколько потоков выполняются параллельно, им выделяется процессорное время на обоих логических процессорах, а не просто кванты времени, как в случае единственного процессора без поддержки Hyper-Threading. Это может заметно повысить производительность и «отзывчивость» систем с поддержкой Hyper-Threading.

Планирование потоков в двухпроцессорной системе с поддержкой Hyper-Threading сложнее; операционная система сначала планирует потоки для физических процессоров, затем для логических, поддерживаемых этими физическими процессорами. То есть при наличии всего двух потоков каждый из них закрепляется за своим физическим, а не логическим процессором. Далее я буду рассматривать лишь однопроцессорные системы с поддержкой Hyper-Threading.

Клиентские и серверные приложения

Ключ к оптимизации приложений для Hyper-Threading — добиться того, чтобы оба логических процессора делали что-нибудь полезное. Я расскажу, как для большей производительности оптимизировать разделение труда между потоками.

Обсуждаемый здесь материал относится как к клиентским, так и к серверным приложениям. На серверах иногда больший смысл имеет прямолинейное разделение труда. Так, если вы разрабатываете ASP.NET-приложение или Web-сервис, ежесекундно обрабатывающий массу запросов, то здесь эффективна традиционная многопоточная архитектура, гарантирующая одновременное выполнение множества потоков. По этой причине существующие серверные приложения показывают большее увеличение производительности при включении Hyper-Threading.

Приложения, которым нужен быстрый ввод-вывод (сетевой или дисковый), выигрывают при распределении операций между несколькими потоками при использовании асинхронной модели. Для клиентских или серверных приложений, где каждый запрос требует интенсивной процессорной обработки, дополнительные преимущества могут быть достигнуты путем распределения работы между несколькими потоками (такой способ почти ничего не дает или даже вредит на однопроцессорных компьютерах без Hyper-Threading из-за слишком частого переключения контекстов). Клиентские приложения, в которых работа передается потоку, отделенному от UI-потока (такой вариант почти всегда оправдывает себя даже просто по соображениям удобства в использовании), на компьютерах с поддержкой Hyper-Threading реагируют на команды пользователя заметно быстрее.

Как задействовать преимущества Hyper-Threading

Hyper-Threading повышает производительность и «отзывчивость» многопоточных приложений. Я расскажу о нескольких подходах, которые позволят максимально повысить производительность. У каждого подхода свои недостатки, поэтому для оценки измеряйте показатели производительности.

Хорошая отправная точка — замерить производительность вашего кода на процессоре с поддержкой Hyper-Threading и без нее (тестировать можно на одном компьютере, если его BIOS позволяет выключать Hyper-Threading). Дополнительный эффект от программирования с учетом Hyper-Threading заключается в том, что это позволит повысить производительность вашего приложения и при выполнении на двухпроцессорной машине. Я поясню, какие подходы специфичны для Hyper-Threading и не будут работать на компьютерах с двумя и более процессорами. Однако приложения, хорошо масштабируемые в многопроцессорных системах, всегда будут чувствовать себя хорошо и в среде с Hyper-Threading.

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

Здесь полезен такой инструмент, как диспетчер задач (TaskMgr.exe), показанный на рис. 1. На вкладке Performance (Быстродействие) отображаются графики использования логических процессоров. Убедитесь, что диспетчер задач настроен на вывод отдельного графика для каждого процессора (View | Select Colums | CPU Usage).

Двухпроцессорная система с поддержкой Hyper-Threading Рис. 1. Двухпроцессорная система с поддержкой Hyper-Threading

Аналогичная информация доступна через счетчик производительности «Processor: % Processor Time» либо для всех процессоров вместе или отдельно для каждого логического процессора. Получить эту информацию удобнее всего через PerfMon.exe (оснастка Performance).

Иногда измерение только загруженности процессора не позволяет определить все операции, интенсивно использующие процессор, так как можно пропустить короткие всплески активности процессора, но такие всплески поможет выявить средство профилирования. В частности, для идентификации функций, сильно нагружающих процессор, удобно средство профилирования в CLR, а также другие средства профилирования, например VTune или поставляемые с Visual Studio 2005 Team System.

Важно отметить, что измерение загруженности процессора через Task Manager или Perfmon может вводить в заблуждение. Операция, нагружающая процессор лишь из одного потока, будет давать загрузку процессора только на 50%. Но, поскольку логические процессоры тесно взаимосвязаны, крайне маловероятно, что полная нагрузка обоих процессоров приведет к удвоению производительности.

В остальной части статьи я исхожу из того, что два потока (T1 и T2) выполняются на двух логических процессорах (LC1 и LC2) на одном физическом процессоре.

Одна голова хорошо, а две быстрее

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

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

Если пользователь работает на компьютере с поддержкой Hyper-Threading, одно из решений заключается в том, чтобы распределять рабочую нагрузку между двумя потоками, выполняемыми одновременно. Это позволит задействовать оба логических процессора. Но вам придется ждать завершения работы обоих потоков. Заметьте: этот подход подразумевает, что обработка каждой части операции занимает одинаковое время; если это предположение неверно, возможности Hyper-Threading не будут полностью задействованы.

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

В листинге 1 показан пример кода, который выполняет числовую обработку массива, не используя преимущества многопоточности. Один из простых способов распределить рабочую нагрузку — разделить массив пополам и обрабатывать каждую половину в отдельном потоке с использованием пула потоков .NET, как показано в примере в листинге 2. Заканчивая работу, поток сообщает об этом, переводя в свободное состояние свой объект-событие ManualResetEvent (основной поток ожидает освобождения этих двух объектов, вызывая WaitHandle.WaitAll).

Листинг 1. Весь код, интенсивно использующий процессор, находится в одном потоке


    public int[] records;

void DoWork(int firstElement, int lastElement, out int result)
{
    result = 0;
    for (int i=firstElement; i<=lastElement; i++)
    {
        int tempvaluei = records[i];
        for (int j=0; j<1000; j++)
        {
            tempvaluei = (tempvaluei * 3 / 2)  +
                ((tempvaluei * tempvaluei) % 70) + 1;
        }
        result += tempvaluei;
    }
    return result;
}

Листинг 2. Использование пула потоков


    public class ThreadWorkItem
{
    public int Result;
    public int FirstElement;
    public int LastElement;
    public ManualResetEvent ManualEvent;
}

void DoWork2(int firstElement, int lastElement, ref int result)
{
    ThreadWorkItem wi1 = new ThreadWorkItem();
    wi1.FirstElement = firstElement;
    wi1.LastElement = (firstElement + lastElement)/2;
    wi1.ManualEvent = new ManualResetEvent(false);

    ThreadWorkItem wi2 = new ThreadWorkItem();
    wi2.FirstElement = wi1.LastElement+1;
    wi2.LastElement = lastElement;
    wi2.ManualEvent = new ManualResetEvent(false);

    ThreadPool.QueueUserWorkItem(new WaitCallback(
        DoWorkCallBack), wi1);
    ThreadPool.QueueUserWorkItem(new WaitCallback(
        DoWorkCallBack), wi2);

    WaitHandle.WaitAll(new ManualResetEvent[]{
        wi1.ManualEvent, wi2.ManualEvent});
    result = wi1.Result + wi2.Result;
}

void DoWorkCallback(object state)
{
    ThreadWorkItem wi = (ThreadWorkItem)state;
    DoWork(wi.FirstElement, wi.LastElement, out wi.Result);
    Wi.ManualEvent.Set();
}

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

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

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

Попадания и промахи кэша

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

Для решения этой проблемы в Pentium 4 предусмотрена двухуровневая система кэширования памяти. Кэш L1 обычно вмещает 8 Кб данных, а кэш L2 — до 512 Кб, но некоторые новые процессоры уже поддерживают до 16 Кб в L1 и 2 Мб в L2. Если запрошенная информация уже находится в кэше L1 (это называют попаданием кэша L1), доступ требует 4 такта, а если она находится в L2-кэше  — 10 тактов. В ином случае нужен запрос к оперативной памяти (это называется промахом кэша), в результате которого время выборки может потребовать более 100 тактов. А если информация выгружена из оперативной памяти на диск, тогда время ожидания достигнет миллионов тактов процессора.

Pentium 4, кэшируя оперативную память, помещает данные в блоки, называемые кэш-линиями. Кэш-линия на всех процессорах с поддержкой Hyper-Threading имеет размер 64 байта, и за одни запрос может быть получено две кэш-линии. Размещая часто используемые данные в памяти по соседству, вы можете повысить производительность приложения, поскольку в этом случае будет гораздо больше шансов на попадание кэша.

Вероятно, вы знаете, что общеязыковая исполняющая среда (CLR) автоматически оптимизирует местоположение (локальность) ссылок подобным образом. Если вы последовательно создаете объекты, они располагаются друг за другом в управляемой куче, что ускоряет последовательный доступ к ним. Кроме того, при сборе мусора кучи уплотняются, чтобы объекты находились ближе друг к другу. (CLR поддерживает Hyper-Threading, и ей известны размеры кэшей, поэтому она осуществляет дополнительную оптимизацию, чтобы полнее использовать преимущества аппаратного обеспечения, на котором она выполняется.) На более быстрых процессорах локальность данных становится еще важнее. Так что хороший стиль — создавать объекты, которые будут использоваться вместе, последовательно, особенно если у них сходные сроки жизни.

Как следствие, производительность многопоточных приложений, запускаемых на процессорах с поддержкой Hyper-Threading, часто улучшается, когда несколько потоков читают из общей области памяти. Если один из потоков загрузит информацию из оперативной памяти в кэш L2, последующие запросы другими потоками той же информации могут быть удовлетворены кэшем L2. При этом информация не будет дважды сохранена в кэше L2, что позволит сэкономить драгоценное пространство кэша. Однако для симметричных многопроцессорных систем, где кэши не разделяются между процессорами, это не так. Поэтому полагаться на такую особенность в многопроцессорной системе не рекомендуется, если только вы не уверены, что оба потока выполняются на одном физическом процессоре. В листинге 3 показан пример кода, который может работать быстрее, если метод DoWork будет выполняться несколькими потоками. Используемый массив может быть общей таблицей в памяти, в частности матрицей преобразования, как в данном случае.

Листинг 3.

    
    public int[] records = ...;
public int[] arr = ...;

void DoWork(int firstElement, int lastElement, out int result)
{
    result = 0;
    for (int i=firstElement; i<=lastElement; i++)
    {
        int tempvaluei = records[i];
        for (int j=0; j<1000; j++)
        {
            tempvaluei = arr[(tempvaluei * 3 / 2 + 1)
                % arr.Length];
        }
        result += tempvaluei;
    }
}

Элементы массива скорее всего уже будут загружены в кэш процессора другими потоками, ранее обращавшимися к нему. Однако может оказаться так, что гораздо быстрее рассчитывать значения из массива «на лету», чем кэшировать их в памяти, например для случая arr[i] = Math.Pow(r,i). Поэтому всегда профилируйте свое приложение, чтобы не промахнуться в выборе подхода.

Разделение между потоками данных, доступных для записи, — это уже совсем другая история. Для поддержания целостности данных понадобится защита на основе блокировок. Без этого возможны ошибки синхронизации. Большинство ошибок синхронизации, крайне редко проявляющихся в однопроцессорных системах, с большой долей вероятности проявятся на процессорах с поддержкой Hyper-Threading или в многопроцессорных системах. Возьмем пример (листинг 4) где два потока одновременно обновляют один и тот же счетчик.

Листинг 4. Ошибки синхронизации

    
    public class ThreadWork
{
    public static int GlobalCounter;

    private ManualResetEvent manualEvent;
    private int countTo;

    public ThreadWork(ManualResetEvent manualEvent,
        int countTo)
    {
        this.manualEvent = manualEvent;
        this.countTo = countTo;
    }

    public void DoWork()
    {
        for(int i=0; i<countTo; i++)
        {
            ThreadWork.GlobalCounter++;
        }
        manualEvent.Set();
    }
}

class TestCounters
{
    public static void TestCounter()
    {
        int numThreads = 10;
        int countTo = 100000;
        ManualResetEvent[] events =
            new ManualResetEvent[numThreads];
        Thread[] threads = new Thread[numThreads];

        ThreadWork.GlobalCounter = 0;
        for (int j=0; j<numThreads; j++)
        {
            events[j] = new ManualResetEvent(false);
            ThreadWork tw = new ThreadWork(events[j], countTo);
            threads[j] = new Thread(
                new ThreadStart(tw.DoWork));
        }
        for (int j=0; j<numThreads; j++)
        {
            threads[j].Start();
        }

        WaitHandle.WaitAll(events);

        Console.WriteLine("Expected:{0} Actual:{1}",
            numThreads * countTo, ThreadWork.GlobalCounter);
    }
}

В действительности операция ThreadWork.GlobalCounter++ состоит из трех операций: выборки значения, хранящегося в GlobalCounter, его увеличения на 1 и сохранения полученного значения обратно в счетчике. Если второй поток получит значение GlobalCounter до завершения операции сохранения другим потоком, он увидит старое значение, в результате чего GlobalCounter будет увеличен лишь раз.

На однопроцессорном компьютере потоки на самом деле выполняются не одновременно, а лишь получают выделенный им квант процессорного времени и обрабатываются процессором, пока не будет вызвана блокирующая операция (например файлового ввода-вывода) или пока не закончится выделенный им квант времени. Значит, переключение потока в середине операции обновления менее вероятно. Но в системах с поддержкой Hyper-Threading (или в многопроцессорных системах) потоки выполняются одновременно, так что вероятность подобной ситуации выше. Например, код в листинге 4 выдает верные результаты на компьютере без поддержки Hyper-Threading, но на моем компьютере с поддержкой Hyper-Threading около 20% выдаваемых значений ошибочны. Это не хорошо.

Дополнительный источник трудно выявляемых ошибок связан с тем, что программист полагается на приоритет потоков при блокировке. В некоторых программах используются потоки с разными приоритетами и при этом делается предположение, что поток с более низким приоритетом не будет выполняться, пока работает поток с более высоким приоритетом. Это опасное предположение даже в однопроцессорных системах, так как поток с более высоким приоритетом может блокироваться при выполнении ввода-вывода или из-за периодического вмешательства планировщика Windows, который стремится предотвратить «голодание» потоков с более низким приоритетом, слишком долго не получающих процессорного времени. Ну а в системах с поддержкой Hyper-Threading процессор обрабатывает первые два потока, готовые к выполнению, а не один (в двухпроцессорной системе эти два потока вдобавок и выполняются на разных физических процессорах). Кроме того, в системах с поддержкой Hyper-Threading поток с меньшим приоритетом может конкурировать за ресурсы процессора с более приоритетным потоком, поскольку они работают в раздельных потоках инструкций, а процессор ничего не знает о приоритетах потоков. В результате фоновые потоки, выполняющие опрос программных компонентов или аппаратных устройств, или другие не особо важные операции, могут отрицательно повлиять на производительность.

Блокировки

Обратите внимание на реализацию блокировок в вашем коде, который будет запускаться на процессорах с поддержкой Hyper-Threading или в многопроцессорных системах. Самое простое исправление для кода в листинге 4 — использовать Interlocked.Increment(ref ThreadWork.globalCounter) вместо ThreadWork.globalCounter++. Для более сложных структур понадобятся и более сложные синхронизирующие объекты типа Monitor, Mutex или Semaphore.

Блокировки замедляют выполнение по двум причинам: вход и выход из блокировок отнимают время, а также, если происходит блокирование потока, обычно происходит переключение контекста. Более того, в системе с поддержкой Hyper-Threading, пока один из выполняемых на процессоре потоков блокирован, кэш L2 может быть недогружен (т. е. использоваться неэффективно). Минимизация числа блокировок, как и времени блокирования, может повысить производительность. С этой целью можно заставить каждый поток обновлять отдельный экземпляр данных (как я и делал в коде (листинг 1) храня wi1.Result и wi2.Result раздельно), и лишь по окончании всех вычислений генерировать результат. Для уменьшения частоты блокировок также можно попытаться сократить время, в течение которого удерживается блокировка.

Производительность можно повысить и за счет реструктуризации кода, чтобы свести к минимуму объем разделяемых данных, доступных для записи, или откладывать обновление общих данных. Хотя в листинге 5 используется более «дорогостоящий» метод lock (который преобразуется компилятором C# в вызовы методов класса Monitor), отказ от ссылок на общую память и выделение операции Interlock из цикла все же может ускорить работу кода. Несмотря на то что Interlock.Increment — значительно более быстрая операция, чем Monitor.Enter, все же она гораздо медленнее увеличения значения переменной на 1, а на процессорах с поддержкой Hyper-Threading или в многопроцессорных системах работает даже медленнее, чем в однопроцессорных.

Листинг 5. Реализация блокировки


    private object syncObj;

public void DoWork()
{
    int counter = 0
    for(int i = 0; i<countTo; i++)
    {
        counter++;
    }
    lock(syncObj)
    {
        ThreadWork.GlobalCounter += counter;
    }
    manualEvent.Set();
}

Ложное разделение и проблема наложения

Избегайте модификации данных, разделяемых между потоками. Храните локальную копию результатов вычислений для каждого из потоков и вычисляйте общий результат по окончании их работы. Это особенно полезно, если размер модифицируемых данных ограничен, так как увеличение рабочего набора приложения тоже влияет на производительность. Я уже упоминал о кэш-линиях и локальности. Допустим, что существует такой класс:

class OClass
{
    public int Field1;
    public int Field2;
}

Если один поток считывает данные из экземпляра OClass, обращаясь к полю Field1, а другой поток на том же процессоре пишет в соседнюю область памяти, используя поле Field2, то, возможно, что Field1 и Field2 окажутся в одной кэш-линии. Это отрицательно повлияет на производительность. И на компьютерах с несколькими физическими процессорами эта проблема проявится еще ярче.

Также не рекомендуется производить запись по адресам памяти в одной кэш-линии сразу из двух потоков. Простой выход — заполнение (padding). Если в OClass будет несколько дополнительных полей между Field1 и Field2, разделение кэш-линии может не произойти. Эта проблема часто возникает при доступе к двум экземплярам, созданным последовательно, потому что CLR размещает их в памяти по соседству. В большинстве случаев это повышает производительность, поскольку такие объекты обычно используются вместе, и весьма вероятно, что после обращения к первому объекту в кэше окажется и соседний объект. Но когда несколько потоков пытаются получить доступ к полям этих объектов, они, возможно, будут совместно использовать кэш-линию (напоминаю, что речь идет о записи), а это снизит производительность. Такая ситуация называется ложным разделением (false sharing). Здесь может помочь заполнение путем добавления полей в класс, либо вы должны позаботиться о том, чтобы эти элементы не создавались один за другим.

Блокировки замедляют выполнение по двум причинам: вход и выход из блокировок отнимают время, а также, если происходит блокирование потока, обычно происходит переключение контекста.

Только не добавляйте неиспользуемые поля в ваши классы просто в надежде на то, что это повысит производительность. Как правило, это не так, да и код может стать запутанным и сложным в сопровождении. Я говорю об этом, поскольку эти соображения важны для понимания того, что происходит за кулисами и где искать источник проблем, если они появятся. Если вы все же решились на добавление неиспользуемых полей после профилирования своего приложения, следите, чтобы эти поля не переупорядочивались компилятором. Также учитывайте эффект от сбора мусора: если объекты O1, O2 и O3 были созданы последовательно, то изначально O1 и O3 не попадут в общую кэш-линию. Но если в результате сбора мусора O2 будет уничтожен, тогда O1 и O3 окажутся рядом.

Рассмотрим набор эффектов, известный как проблема наложения (aliasing problem). Эта проблема возникает из-за архитектуры кэша процессора, известной под названием «N-направленный ассоциативный кэш» (N-Way associative cache). Данные в областях памяти (поделенные на блоки, кратные 2 Кб для кэша L1 размером 8 Кб, или на блоки, кратные 64 Кб для кэша L2 размером 512 Кб либо 128 Кб для кэша L2 размером 1 Мб и т. д.) совместно используют некий участок кэша и конкурируют за n ячеек (spots) кэша (n обычно равно 2, 4 или 8). Например, данные по адресам 0×00010, 0×08010, 0×38010 и 0×90010 делятся на блоки, кратные 32 Кб (0×8000), и совместно используют некий участок в кэше L2. То есть, если будут повторные обращения более чем к n этих адресов (одним или более потоками), по некоторым из них придется считывать данные из оперативной памяти, что негативно скажется на производительности. Проблема наложения существует во всех процессорах Pentium 4, кроме самых новейших, в которых адреса памяти делятся на блоки, кратные 64 Кб.

Эта проблема влияет не только на неуправляемый код, но и на управляемый. Если в вашей программе на C# имеется массив целых значений, каждое из которых занимает 4 байта, и поток A обращается к элементу [i], когда поток B получает доступ к элементу [i + 16 Кб], то используемые области памяти будут расположены «на расстоянии» в 64 Кб. Избегайте разнесения объектов, на которые ссылаются параллельные потоки, на удаление в 16 Кб (или кратные значения). Существуют и другие, реже проявляющиеся проблемы процессорных ресурсов; в целом, для операций, интенсивно использующих процессор, старайтесь ограничить до 4 или 8 число параллельных потоков, разделяющих какие-либо данные.

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

Конкуренция

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

Если пример в листинге 1 переориентировать на использование операций с плавающей точкой (как показано в листинге 6), выигрыш от разделения задачи между несколькими потоками исчезнет, а когда рабочие потоки не будут выполнять ничего, кроме операций с плавающей точкой, производительность может упасть на 40% из-за неэффективного использования ресурсов процессора.

Листинг 6. Вычисления с плавающей точкой


    public float[] records;

void DoWork (int firstElement, int lastElement, out int result)
{
    result = 0;
    for (int i=firstElement; i<=lastElement; i++)
    {
        float tempvalue = records[i];
        for (int j=0; j<1000; j++)
        {
            tempvalue = (Math.Sin(tempvalue) * 3 /2) +
                ((tempvaluei * tempvaluei) % 70) + 1;
        }
        result += tempvalue;
    }
}

Заметьте, что в Pentium 4 только один блок FPU, вследствие чего мы не получим выигрыша, пытаясь планировать выполнение операций с плавающей точкой на обоих логических процессорах. Производительность будет выше, когда операции с плавающей точкой выполняются лишь одним потоком на физическом процессоре.

Серверные приложения и Hyper-Threading

Серверные приложения, обрабатывающие множество одновременных запросов, в большинстве случаев выполняют каждую операцию в отдельном потоке, используя все логические процессоры и зачастую ставя рабочие элементы в очередь к пулу потоков. В результате многие серверные приложения (вроде ASP.NET-приложений) получают преимущества от Hyper-Threading без всякий изменений в коде. Единственная проблема, о которой следует помнить при оптимизации серверных приложений под Hyper-Threading, — это совместный доступ к данным, уже обсуждавшийся нами ранее.

В случае ASP.NET учтите возможность конфигурации типа Web-сада. По умолчанию в такой конфигурации процесс с привязкой к какому-либо логическому процессору поддерживает собственную копию данных и обслуживает отдельный набор запросов. Так как конфигурация типа web-сада ASP.NET базируется на логических процессорах, а не физических, есть вероятность, что будут задействованы все логические процессоры, но совместного использования кэша между логическими процессорами на одном физическом процессоре не будет.

Привязка к процессорам

Обычно поток планируется для выполнения на любом доступном в системе логическом процессоре. Но иногда нужен более точный контроль над тем, на каком из процессоров будет выполняться поток. Это можно сделать через привязку к процессорам (affinity).

Привязка к процессорам определяется битовой маской, указывающей процессоры, на которых разрешается выполнение процесса или потока. Эту информацию можно получить P/Invoke-вызовом API-функции GetProcessAffinityMask:

[DllImport("kernel32.dll", SetLastError=true)]
static extern int GetProcessAffinityMask (int hProcess,
  ref int lpProcessAffinityMask, ref int systemAffinityMask);

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

На однопроцессорной машине без поддержки Hyper-Threading будет установлен только младший бит в systemAffinityMask (1). На однопроцессорной машине с поддержкой Hyper-Threading или на двухпроцессорном компьютере без Hyper-Threading будут установлены два младших бита, и systemAffinityMask будет равна 1+2=3.  На двухпроцессорной машине с поддержкой Hyper-Threading значение systemAffinityMask составит 1+2+4+88=15.

При включенной поддержке Hyper-Threading операционная система присваивает номер каждому логическому процессору. В системе с n процессорами первый физический процессор будет представлен как логические процессоры 0 и n, второй физический процессор — как логические процессоры 1 и n+1.  Физические процессоры [A,B]  теперь будут рассматриваться как логические процессоры [0(A), 1(B), 2(A), 3(B)]. Определить число процессоров, установленных в компьютере, можно простым подсчетом битов в systemAffinityMask. (Эту информацию можно получить и через другие функции Windows API. Однако маска привязки позволяет узнать, сколько логических процессоров доступно вашему процессу, что предпочтительнее.)

Пример в листинге 7 показывает, как задать маску привязки процесса. Если установить ее так, чтобы процесс использовал только по одному логическому процессору на каждом физическом, то приложение будет вести себя так, будто поддержка Hyper-Threading отключена.

Листинг 7. Маска привязки процесса


    public void SetProcessAffinityToPhysicalCPUForHyperthreadOnly(
    int processid)
{
    int res;
    int hProcess;
    int ProcAffinityMask = 0, SysAffinityMask = 0;
    hProcess  = OpenProcess(PROCESS_ALL_ACCESS, 0, processid);
    res = GetProcessAffinityMask(
        hProcess, ref ProcAffinityMask, ref SysAffinityMask);
    if (SysAffinityMask == 3) // 1 физический, 2 логических
        res = SetProcessAffinityMask(hProcess, 1);
    else if (SysAffinityMask == 15) // 2 физических,
                                    // 4 логических
        res = SetProcessAffinityMask(hProcess, 3);
    res = CloseHandle(hProcess);
}

Но здесь есть один подвох. Код на рис. 8 предполагает, что Hyper-Threading включена. Соответствующий процесс и все порождаемые им процессы будут выполняться только на логических процессорах с меньшим номером на каждом физическом процессоре. Этот подход позволяет примерно оценить производительность приложения в отсутствие Hyper-Threading, не требуя реального отключения этой технологии.

Если маска привязки процесса (возвращаемая в processAffinityMask) отличается от системной маски привязки, потоки в процессе могут работать только на выбранных логических процессорах. Это можно сделать через диспетчер задач или SetProcessAffinityMask. Чтобы изменить маску привязки процесса через диспетчер задач, щелкните правой кнопкой мыши нужный процесс в списке и выберите Set Affinity (Задать соответствие).

Аналогично SetThreadAffinityMask позволяет ограничить выполнение потока только на указанных процессорах. Никогда не изменяйте привязку потока из пула ThreadPool, так как эти потоки используются повторно. Также обратите внимание на то, что управляемый поток не всегда выполняется на основе системного потока. Например, если ваш код выполняется как хранимая процедура в SQL Server 2005, вполне вероятно, что он будет отображен на волокно (fiber).

Если по какой-то причине вам нужно контролировать операции на каждом логическом процессоре, вы могли бы создать столько же потоков, сколько логических процессоров, и привязать первый поток к первому логическому процессору, второй — ко второму логическому процессору и т. д. Когда вы задаете потоку маску привязки, он не будет выполняться, если логический процессор, указанный в маске, окажется недоступен. Чтобы предотвратить эту ситуацию, благоразумнее использовать нежесткую привязку, вызвав SetThreadIdealProcessor через P/Invoke.

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

Источник и потребитель

Кэширование — очень эффективное и масштабируемое решение при наличии шаблона взаимодействия потоков «источник-потребитель». Поток-источник осуществляет преобразование какой-то части данных, сохраняет результат в буфере, а затем переходит к следующей порции данных. Второй поток (потребитель) обрабатывает данные в буфере и выдает окончательный результат.

В этом случае содержимое памяти, обрабатываемое потребителем, скорее всего уже находится в кэше процессора, если поток-источник и поток-потребитель выполняются на одном физическом процессоре. Эти потоки могут работать параллельно на разных логических процессорах. Для масштабирования на многопроцессорных компьютерах или в случае, когда источник или потребитель требуют ввода-вывода, можно использовать несколько потоков-источников и потоков-потребителей; однако производительность будет выше, если все они будут работать с одними и теми же данными и выполняться на одном физическом процессоре. Этот подход помогает и в том случае, когда работу нельзя заранее точно распределить (например, когда некоторые элементы данных требуют более длительной обработки, а вам не известно, какие это элементы).

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

Распознавание поддержки Hyper-Threading

Обычно необходимости в этом нет, так как приложения, как правило, работают с любым количеством логических процессоров. Если же такое распознавание действительно необходимо, то сделать это из управляемого кода довольно трудно. В Windows Server 2003 эта информация предоставляется функцией GetLogicalProcessorInformation (через механизм P/Invoke); однако этой API-функции нет в Windows XP. Получить нужную информацию в Windows XP можно, напрямую выдав команду ассемблера CPUID, но здесь я не буду вдаваться в детали этого подхода.

Заключение

Hyper-Threading — это первый шаг на пути к внедрению в настольные компьютеры процессоров с несколькими взаимозависимыми логическими процессорами. Я показал разные способы оптимизации кода для компьютеров с поддержкой Hyper-Threading путем распределения рабочей нагрузки между несколькими потоками. Наметившиеся тенденции в архитектуре процессоров сделают многопоточность более полезным средством для увеличения производительности приложений — как серверных, так и клиентских (см. статью Герба Саттера (Herb Sutter) по параллелизму). Каждый разработчик лишь выиграет от поддержки многопоточности в своих приложениях.

К началу страницы