C# и наука: применение языковых средств C# в проектах для научных вычислений

Автор:

  • Фахад Гилани

Язык C# довольно успешно использовался в проектах многих типов, в том числе для разработки Web-приложений, баз данных, GUI и т. д. Одним из последних рубежей применения C# кода вполне могут стать научные вычисления. Но может ли C# сравниться с FORTRAN и C++ в научных и математических проектах? В статье автор отвечает на этот вопрос, исследуя общеязыковую исполняющую среду (common language runtime, CLR) в .NET, чтобы определить, как JIT компилятор, промежуточный язык Microsoft (Microsoft intermediate language, MSIL) и сборщик мусора (garbage collector) влияют на производительность. Он также рассматривает типы данных C#, включая массивы и матрицы, и другие языковые средства, играющие важную роль в приложениях для научных вычислений. Эта статья предполагает знание C#.

Язык C# заслужил большое уважение и популярность среди разработчиков самых разных программных продуктов. Последнюю пару лет C# играл важную роль в производстве устойчивых к сбоям продуктов — от настольных приложений до Web сервисов, от высокоуровневых решений в автоматизации бизнес-процессов до программ системного уровня и от однопользовательских продуктов до корпоративных решений в сетевых распределенных средах. Зная о мощных средствах этого языка, можно задаться вопросом: нельзя ли использовать C# и Microsoft .NET Framework не только для GUI- и Web-компонентов? Готово ли научное сообщество воспринимать их всерьез при разработке кода для высокопроизводительных вычислений?

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

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

Вычисления в науке

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

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

Производительность и языки

Одним из главных отличий среди языков, применяемых в научном программировании, была производительность. Компиляторы и генераторы кода часто считаются факторами, ограничивающими производительность, но это утверждение не вполне корректно. Например, наиболее распространенные компиляторы C++ хорошо справляются с генерацией и оптимизацией кода. Здесь есть некоторые тонкости, но обычно они не так важны, как эффективность кода. Скажем, в C++ следует избегать создания слишком большого количества временных объектов, тем более что в этом языке очень легко неосознанно создать массу безымянных временных объектов. Поэтому лучше использовать шаблоны выражений, позволяющие отложить реальные вычисления математического выражения до его присвоения. В результате вы избежите больших потерь из-за абстракций в период выполнения.

Нельзя сказать, что единственный фактор, влияющий на производительность, — особенности языка. При сравнении быстродействия языков на самом деле сравнивается мастерство создателей компиляторов, а не сами языки. Если вы можете получить приемлемое быстродействие от языка, исполняющей среды или платформы, то выбор становится делом вкуса.

Если у вас есть опыт программирования на C# и вы подумываете о выполнении серьезных научных расчетов, прибегать к другому языку не нужно — C# более чем достаточно.

MSIL и портируемость

Как и остальные .NET-ориентированные языки, C# компилируется в MSIL (Microsoft intermediate language), который выполняется в общеязыковой исполняющей среде (CLR). CLR можно упрощенно представить как комбинацию оптимизирующего JIT компилятора и сборщика мусора. C# предоставляет и использует большую часть функциональности CLR, поэтому важно детальнее рассмотреть, что происходит в исполняющей среде.

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

CLR позволяет писать приложения и библиотеки на нескольких языках, компилируемых в MSIL. Затем MSIL может быть запущен в любой поддерживающей его архитектуре. Теперь ученые могут писать свои математические библиотеки на FORTRAN, задействовать их в C++ и использовать C# и ASP.NET для публикации результатов в Интернете.

CLR — в отличие от Java Virtual Machine (JVM) — это универсальная среда и целевая платформа для многих языков программирования. Более того, CLR обеспечивает взаимодействие на уровне данных, а не только на уровне приложений, и позволяет разделять ресурсы между языками.

Есть много языков, для которых существуют компиляторы, генерирующие MSIL-код. К ним, в частности, относятся Ada, C, C++, Caml, COBOL, Eiffel, FORTRAN, Java, LISP, Logo, Mixal, Pascal, Perl, PHP, Python, Scheme и Smalltalk. Кроме того, пространство имен System.Reflection.Emit предоставляет функциональность, значительно упрощающую создание новых компиляторов под CLR.

Перенос CLR на другие архитектуры уже ведется, но эта работа не закончена. Однако Mono/Ximian разработали реализацию с открытым исходным кодом для архитектур s390, SPARC и PowerPC, а также для систем StrongARM. Microsoft выпустила версию с открытым исходным кодом, работающую на системах FreeBSD, включая Mac OS X.

Dd335957.arr(ru-ru,MSDN.10).gif Детали см. в статье Джейсона Виттингтона (Jason Whittington) Rotor: Shared Source CLI Provides Source Code for a FreeBSD Implementation of .NET (EN) из июльского номера MSDN Magasine за 2002 г.

Эти разработки появились всего за последние несколько лет. Вероятно, через некоторое время полнофункциональная CLR станет доступна для всех распространенных архитектур.

Стал ли JIT компилятор эффективнее?

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

В научном программировании это может быть удобно. Код научных расчетов в основном выполняет операции над числами. Чтобы осуществлять такие вычисления за приемлемое время, следует корректно использовать некоторые аппаратные ресурсы. Хотя многие статические компиляторы хорошо оптимизируют код, динамическая природа JIT компилятора позволяет ему оптимизировать использование ресурсов с помощью таких методов, как распределение регистров на основе приоритета (priority-based register allocation), «ленивая» выборка кода (lazy code selection), подстройка кэша (cache-tuning) и специфичные для процессора оптимизации (CPU-specific optimizations). Эти методы также открывают возможности для более тонкой оптимизации вроде разложения сложных команд (strength reduction), подстановки значений констант (constant propagation), избыточной загрузки после записи (redundant load-after-store), исключение общих подвыражений (common sub-expression elimination), исключение проверки границ массивов (array bounds check elimination), подстановки тел методов (method inlining) и т. д. Хотя для JIT компилятора такие продвинутые виды оптимизации возможны, в текущей версии .NET они не поддерживаются.

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

JIT компилятор, поставляемый с .NET Framework 1.1, значительно усовершенствован в сравнении со своим предшественником версии 1.0. График на рис. 1 иллюстрирует сравнение производительности между CLR версии 1.0 и 1.1 при выполнении комплекса эталонных тестов SciMark 2.0 на двух платформах. Для тестирования использовалась машина с процессором Pentium 4 с тактовой частотой 2.4 ГГц и 256 МБ оперативной памяти.

Изображение GIF  Рис. 1. Усовершенствование JIT в .NET 1.1

Эталонные тесты SciMark состоят из нескольких ядер, моделирующих наиболее распространенные в научных приложениях вычислительные операции; каждое из них имеет свои особенности в доступе к памяти и наборе операций с плавающей точкой. Вот эти ядра: быстрые преобразования Фурье (Fast Fourier Transformations, FFT), итерации последовательных сверхрелаксаций (Successive Over-Relaxation iterations, SOR), квадратура Монте-Карло (Monte-Carlo quadrature), умножение разреженных матриц (sparse matrix multiplications) и разложение плотных матриц на множители (dense matrix factorization) для решения комплексных линейных систем.

SciMark изначально разработан на Java (math.nist.gov/scimark  (EN)) и перенесен на C# Крисом Ри (Chris Re) и Венером Вогелсом (Wener Vogels). Следует заметить, что эта реализация не использует небезопасный код (unsafe code), который мог бы дать небольшое приращение скорости примерно на 5—10%.

Рис. 1 отражает общий результат в миллионах операций с плавающей точкой в секунду (MFLOPS) для двух версий .NET Framework. Это должно дать вам представление о быстродействии текущей версии (1.1) и о вероятном его повышении в будущих версиях.

График показывает, что CLR 1.1 весьма значительно превосходит версию 1.0 (а именно на 54.1 MFLOPS). Версия 1.1 сочетает в себе ряд усовершенствований в общей реализации, в том числе добавление в JIT компилятор оптимизаций, специфичных для целевой архитектуры, вроде приведения double к integer с использованием инструкций SSE2 в процессорах с архитектурой IA-32. Разумеется, компилятор генерирует оптимизированный код и для других процессоров.

Думаю, что следующая версия JIT компилятора будет работать еще лучше, и способность JIT компиляторов генерировать более быстрый код, чем создаваемый статическими компиляторами, — лишь вопрос времени.

Автоматическое управление памятью

С точки зрения реализации, автоматическое управление памятью наверное, — лучший подарок CLR разработчикам. Память выделяется относительно быстро (указатель кучи просто перемещается на следующий свободный слот) — по сравнению с более медленным и расточительным просмотром списка свободных страниц в вызовах malloc или new в C/C++. Более того, в период выполнения память управляется автоматически, освобождая и уплотняя незадействованное пространство. Программистам больше не надо гоняться за указателями, преждевременно освобождая блоки памяти или не освобождая их вовсе (хотя такие языки, как C# и Visual C++ по-прежнему дают такую возможность).

Реакция многих разработчиков на мысль об использовании сборщика мусора вполне предсказуема. Хотя сборщики мусора действительно приводят к некоторым издержкам в интенсивно работающих с памятью приложениях, они все же берут на себя все запутанные детали отслеживания утечек памяти и очистки «брошенных» указателей. Сборщики мусора постоянно управляют кучей, сжимают ее и обеспечивают повторное использование.

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

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

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

Листинг 1. Перемножение матриц не вызывает сбора мусора

using System;

/// <summary>
/// Класс, представляющий матрицу
/// </summary>
class Matrix
{
    double[,] matrix;
    int rows, columns;

    // Не вызывается до закрытия приложения
    ~Matrix()
    {
        Console.WriteLine("Finalize");
    }

    public Matrix(int sizeA, int sizeB)
    {
        rows = sizeA;
        columns = sizeB;
        matrix = new double[sizeA, sizeB];
    }

    // Индексатор для установки/получения элементов внутреннего массива
    public double this[int i, int j]
    {
        set { matrix[i,j] = value; }
        get { return matrix[i,j]; }
    }

    // Возвращает число строк в матрице
    public int Rows
    {
        get { return rows; }
    }

    // Возвращает число столбцов в матрице
    public int Columns
    {
        get { return rows; }
    }

}

/// <summary>
/// Пример перемножения матриц
/// </summary>
class MatMulTest
{
    [STAThread]
    static void Main(string[] args)
    {
        int i, size, loopCounter;
        Matrix MatrixA, MatrixB, MatrixC;
        size = 200;

        MatrixA = new Matrix(size,size);
        MatrixB = new Matrix(size,size);
        MatrixC = new Matrix(size,size);

        /* Инициализируем матрицы случайными значениями */
        for (i=0; i<size; i++)
        {
            for (int j=0; j<size; j++)
            {
                MatrixA [i,j]= (i + j) * 10;
                MatrixB [i,j]= (i + j) * 20;
            }
        }

        loopCounter = 1000;
        for (i=0; i < loopCounter; i++) Matmul(MatrixA,
            MatrixB, MatrixC);

        Console.WriteLine("Done.");
        Console.ReadLine();
    }

    // Подпрограмма перемножения матриц
    public static void Matmul(Matrix A, Matrix B, Matrix C)
    {
        int i, j, k, size;
        double tmp;

        size = A.Rows;
        for (i=0; i<size; i++)
        {
            for (j=0; j<size; j++)
            {
                tmp = C[i,j];
                for (k=0; k<size; k++)
                {
                    tmp += A[i,k] * B[k,j];
                }
                C[i,j] = tmp;
            }
        }
    }
}

В коде листинга 1 определен класс Matrix, в котором объявляется двухмерный массив для хранения данных матрицы. Метод Main создает три экземпляра этого класса с размерностью по 200×200 (каждый объект занимает примерно 313 Кб). Ссылка на каждую из этих матриц передается по значению методу Matmul (по значению передаются сами ссылки, а не реальные объекты), который затем перемножает матрицы A и B, а результат сохраняет в матрице C.

Для большего интереса метод Matmul вызывается в цикле тысячу раз. Иными словами, я повторно использовал эти объекты для выполнения тысячи «разных» перемножений матриц и ни разу не инициировал сбор мусора. Следить за числом операций сбора мусора можно при помощи счетчиков производительности памяти, предоставляемых CLR.

Однако при вычислениях с использованием более крупных блоков памяти сбор мусора окажется совершенно неизбежен, как только будет запрошено большее пространство, чем есть в наличии. В таких ситуациях можно прибегнуть к альтернативе, например выделить критичные к быстродействию участки в неуправляемый код и вызывать их из управляемого C#-кода. Но хочу предостеречь: P/Invoke или вызовы .NET interop сопряжены с некоторыми издержками периода выполнения, поэтому такой способ следует использовать в последнюю очередь или в том случае, когда гранулярность операций достаточно груба, чтобы оправдать затраты на вызов.

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

Теперь перейдем от CLR к самому языку. Как я уже упоминал, C# обладает рядом качеств, делающих его вполне пригодным для научных расчетов. Рассмотрим некоторые из них.

Ориентация на объекты

C# — объектно-ориентированный язык. Поскольку реальный мир состоит из тесно взаимосвязанных объектов с динамическими свойствами, объектно-ориентированное программирование (ООП) зачастую лучше подходит для решения задач, связанных с научными расчетами. Более того, структурированный объектно-ориентированный код легче модифицировать, заменяя внутренние части в соответствии с изменениями в научных моделях.

Однако не все научные проблемы можно выразить через объекты и их отношения — в таких случаях ориентация на объекты приводит к лишнему усложнению. (Например, код перемножения матриц в листинге 1 можно было бы написать без использования специального класса матриц, объявив три многомерных массива внутри одного класса.) Кроме того, между объектами возможно сложное взаимодействие, которое в программной реализации приведет к еще большему усложнению или нежелательным издержкам. Для примера возьмем молекулярную динамику. Молекулярная динамика широко используется в вычислительной химии, физике, биологии и материаловедении. Она была одной из первых областей научного применения компьютеров: в 1957 году Элдер (Alder) и Уэйнрайт (Wainwright) смоделировали движения примерно 150 атомов аргона. В молекулярной динамике ученых интересует моделирование взаимодействия атомов через парные потенциалы (pairwise potentials), аналогичного тому, как гравитация влияет на взаимодействие между солнцем, планетами, их спутниками и звездами. Моделирование взаимодействия между двумя атомами с использованием ООП может быть оправданным. Но представьте воображаемый куб, содержащий N3 атомов, где N — очень большое число. В итоге уравнения для вычисления всех парных сил и энергий могут оказаться настолько сложными, что для расчетов придется отказаться от ориентации на объекты и отдать предпочтение традиционному процедурному подходу. Но и процедурный код может оказаться менее производительным. Все зависит от способа хранения данных и применяемых алгоритмов.

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

Высокоточные операции с плавающей точкой

В научном коде нельзя игнорировать точность и корректность преобразований. Даже самые мощные современные компьютеры могут обеспечить точность лишь конечным числом разрядов. Значимость точности трудно переоценить — она подтверждается катастрофами, которые происходили из-за арифметических ошибок; вспомните, например, известный взрыв непилотируемой ракеты «Ариан 5» в 1996 году (64-битное число с плавающей точкой в системе инерциальной ориентации было неправильно преобразовано в 16-битное целое со знаком… и последовал взрыв!). Конечно, вы вряд ли разрабатываете программное обеспечение для ракет, но важно понимать, насколько и почему во многих научных приложениях столь важна высокая точность, а также почему стандарт IEEE 754 для двоичной арифметики с плавающей точкой может оказаться скорее сковывающим, чем полезным.

C# позволяет использовать в арифметических операциях с плавающей точкой более точные типы данных на аппаратных платформах с соответствующей поддержкой, например 80 битный формат Intel двойной точности. Этот тип имеет большую точность, чем double, и неявно задействуется нижележащим оборудованием при выполнении всех вычислений с плавающей точкой, давая абсолютно корректные или очень близкие к тому результаты. Следующая цитата взята прямо из раздела 4.1 спецификации C#: «…в выражениях вида x*y/z, где умножение дает результат, выходящий за пределы диапазона значений double, но последующее деление возвращает промежуточный результат обратно в этот диапазон, тот факт, что выражение вычисляется в формате более широкого диапазона может дать конечный результат вместо бесконечности».

C# также поддерживает тип decimal — 128-битный тип данных, пригодный для финансовых и валютных расчетов.

Типы значений, или «облегченные» объекты

В C# существуют две основные категории типов: ссылочные (reference types) («тяжеловесные» объекты) и типы значений, или значимые типы (value types) («облегченные» объекты).

Память под ссылочные типы всегда выделяется из кучи (единственное исключение — использование ключевого слова stackalloc); они создают дополнительный уровень абстракции, т. е. требуют доступа через ссылку на место их хранения. Поскольку к этим типам нельзя обращаться напрямую, переменная ссылочного типа всегда хранит ссылку на реальный объект (или null), а не сам объект. А так как память под них выделяется из кучи, исполняющая среда должна быть уверена в правильности каждого запроса на выделение. Рассмотрим следующий код, который может привести к успешному выделению памяти:

Листинг 2.

Matrix m = new Matrix(100, 100);

За кулисами диспетчер памяти CLR получает запрос на выделение, вычисляет необходимый объем памяти для хранения объекта с его заголовком и переменных класса. Затем диспетчер памяти проверяет, достаточно ли свободного места в куче. Если да, объект успешно создается и возвращается ссылка на место его хранения. Если памяти для хранения объекта недостаточно, запускается сборщик мусора для освобождения места и сжатия кучи.

Если процесс прошел успешно, диспетчер памяти должен предпринять еще одно важное действие, прежде чем объект будет записан в память. Оно необходимо для поддержки сбора мусора по поколениям (generational garbage collection) и заключается в том, что генерируется блок кода, называемый барьером записи (write barrier). (Детали реализации сбора мусора по поколениям выходят за рамки этой статьи.) В свою очередь исполняющая среда генерирует барьер записи всякий раз, когда объект записывается по определенному адресу в памяти или когда объект ссылается на другой объект в памяти (например, объект более старшего поколения указывает на объект младшего поколения). Одна из многих деталей, о которых следует помнить, чтобы не нарушить сбор мусора по поколениям, — наличие таких записей у объектов, чтобы они не были ошибочно собраны как мусор в тот момент, когда на них ссылается какой-то объект другого поколения. Как вы, наверное, догадались, барьеры записи создают небольшие издержки в период выполнения, поэтому создание миллионов объектов — не лучший вариант для научных приложений.

Значимые типы хранятся непосредственно в стеке (хотя есть исключение, о котором я вскоре расскажу). Таким типам не требуется дополнительный уровень абстракции и поэтому переменные значимых типов всегда хранят само значение, а не ссылки на другие типы (и поэтому не могут содержать null). Главное преимущество значимых типов по сравнению со ссылочными в том, что создание их экземпляров не приводит к большим издержкам. Память под них выделяется в стеке простым приращением указателя стека, и они не управляются диспетчером памяти. Эти объекты никогда не инициируют сбор мусора. Более того, для значимых типов барьер записи не генерируется.

Примеры значимых типов в C# — элементарные типы данных (int, float, single, byte), перечислимые и структуры. Кстати, сказав ранее, что значимые типы хранятся прямо в стеке, я не употребил слово «всегда», как для ссылочных типов. Тип значения, размещенный внутри ссылочного типа, будет храниться не в стеке, а в куче. Например, взгляните на такой фрагмент кода:

Листинг 3.

class Point
{
    private double x, y;
    
    public Point (double x, double y)
    {
        this.x = x; this.y = y;
    }
}

Экземпляр этого класса займет 24 байта, из которых 8 байтов пойдет на заголовок объекта, а оставшиеся 16 — на две переменных типа double: x и y. В то же время размещение ссылочного типа внутри объекта значимого типа (например массива внутри структуры) не приведет к размещению всего объекта в куче. В куче будет выделен только массив, а ссылка на него размещена в структуре в стеке.

Значимые типы наследуют от System.ValueType, который в свою очередь наследует от System.Object. Поэтому значимые типы поддерживают функциональность, подобную той, которая есть у классов. У них могут быть конструкторы (кроме параметризованных), методы, индексаторы и перегруженные операторы; они также могут реализовать интерфейсы. Однако от них нельзя наследовать, и сами они не способны наследовать от других типов. Эти объекты легко поддаются множеству JIT оптимизаций, что приводит к созданию эффективного высокопроизводительного кода.

Загвоздка в том, что можно случайно внести значимые типы в объект и они окажутся в куче, — этот процесс известен как упаковка (boxing). Убедитесь, что значения в вашем коде не упаковываются без необходимости, иначе вы потеряете изначально достигнутое быстродействие. Учтите также, что массивы значимых типов (например массивы типа double или int) хранятся в куче, а не в стеке. В стеке хранится лишь значение, содержащее ссылку на такой массив. Причина в том, что все типы массивов неявно наследуют от System.Array и являются ссылочными типами.

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

Комплексная арифметика

Хотя C#, как и C, не поддерживает внутренний тип данных для комплексных чисел, вы вправе создать собственный. Это можно сделать с помощью структуры с семантикой значений (value semantics).

Комплексное число — это упорядоченная пара вещественных чисел (скажем, x и y), умноженная на мнимое число i; комплексные числа подчиняются определенным правилам. В этом примере z — комплексное число:

z = x + yi, где i2 = -1 

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

В листинге 4 показана базовая реализация пользовательского типа данных для комплексных чисел и ее применение. Здесь представлены два разных аспекта C#, имеющих большое значение для научных расчетов. Это применение структур — ценного ресурса для создания собственных типов данных, которые удобно рассматривать как просто дополнительные элементарные типы (элементарными типами данных в C# являются все структуры), — и перегрузка бинарных и унарных операторов в пользовательских типах, что значительно улучшает восприятие и сопровождение кода в научных приложениях. Этот аспект заслуживает более пристального внимания. Перегрузка операторов улучшает в основном читабельность кода, что крайне важно в научных расчетах. Хотя того же эффекта можно добиться с помощью вызовов методов, не всегда хорошо, когда в коде присутствует множество сложных числовых выражений. Для примера возьмем простое выражение с тремя комплексными числами:

C3 = C1 * C2 + 5 * (C1-13) 

Листинг 4. Пользовательский тип для комплексных чисел

using System;

/// <summary>
/// Реализация комплексного числа одинарной точности
/// </summary>
public struct Complex
{

    // Вещественная и мнимая части комплексного числа
    private float real, imaginary;

    public Complex(float real, float imaginary)
    {
        this.real = real;
        this.imaginary = imaginary;
    }

    // Аксессоры для доступа/установки закрытых переменных
    public float Real
    {
        get { return real; }
        set { real = value; }
    }

    public float Imaginary
    {
        get { return imaginary; }
        set { imaginary = value; }
    }

    //////////////////////////////////////////////
    //
    //  Неявные и явные операторы преобразования
    //

    // Неявное преобразование комплексного числа
    // в число с плавающей точкой
    public static implicit operator float(Complex c)
    {
        return c.Real;
    }

    // Явное преобразование числа с плавающей точкой в комплексное
    // (требует явного приведения)
    public static explicit operator Complex(float f)
    {
        return new Complex(f, 0);
    }

    //////////////////////////////////////////////
    //
    //  Перегруженные арифметические операторы:
    //  +, -, *, /, ==, !=
    //

    public static Complex operator +(Complex c)
    {
        return c;
    }

    public static Complex operator -(Complex c)
    {
        return new Complex(-c.Real, -c.Imaginary);
    }

    public static Complex operator +(Complex c1, Complex c2)
    {
        return new Complex(c1.Real + c2.Real, c1.Imaginary +
            c2.Imaginary);
    }

    public static Complex operator +(Complex c1, float num)
    {
        return new Complex(c1.Real + num, c1.Imaginary);
    }

    public static Complex operator +(float num, Complex c1)
    {
        return new Complex(c1.Real + num, c1.Imaginary);
    }

    public static Complex operator -(Complex c1, float num)
    {
        return new Complex(c1.Real - num, c1.Imaginary);
    }

    public static Complex operator -(float num, Complex c1)
    {
        return new Complex(c1.Real - num, c1.Imaginary);
    }

    public static Complex operator -(Complex c1, Complex c2)
    {
        return new Complex(c1.Real - c2.Real, c1.Imaginary -
            c2.Imaginary);
    }

    public static Complex operator *(Complex c1, Complex c2)
    {
        return new Complex((c1.Real * c2.Real) –
        (c1.Imaginary * c2.Imaginary),
            (c1.Real * c2.Imaginary) + (c1.Imaginary *
            c2.Real));
    }

    public static Complex operator *(Complex c1, float num)
    {
        return new Complex(c1.Real*num, c1.Imaginary*num);
    }

    public static Complex operator *(float num, Complex c1)
    {return new Complex(c1.Real * num, c1.Imaginary*num);}

    public static Complex operator /(Complex c1, Complex c2)
    {
        float div = c2.Real*c2.Real + c2.Imaginary*c2.Imaginary;
        if (div == 0) throw new DivideByZeroException();

        return new Complex((c1.Real*c2.Real +
            c1.Imaginary*c2.Imaginary)/div,
                (c1.Imaginary*c2.Real –
                c1.Real*c2.Imaginary)/div);
    }

    public static bool operator ==(Complex c1, Complex c2)
    {
        return (c1.Real == c2.Real) && (c2.Imaginary == c2.Imaginary);
    }

    public static bool operator !=(Complex c1, Complex c2)
    {
        return (c1.Real != c2.Real) || (c2.Imaginary != c2.Imaginary);
    }

    public override int GetHashCode()
    {
        return (Real.GetHashCode() ^ Imaginary.GetHashCode());
    }

    public override bool Equals(object o)
    {
        return (o is Complex)? (this == (Complex)o): false;
    }

    // Отображение комплексного числа в натуральном виде
    // ------------------------------------------------------------
    // Обратите внимание: вызов этого метода упакует значение
    // в строковый объект и тем самым приведет к его созданию
    // в куче с размером в 24 байта
    public override string ToString()
    {
        return(String.Format("{0} + {1}i", real, imaginary));
    }
}

/// <summary>
/// Класс для тестирования типа комплексного числа
/// </summary>
public class ComplexNumbersTest
{
    public static void Main()
    {

        // Создаем два комплексных числа
        Complex c1 = new Complex (2,3);
        Complex c2 = new Complex (3,4);

        // Выполняем арифметические операции
        Complex eq1 = c1 + c2 * -c1;
        Complex eq2 = (c1==c2)? 4*c1: 4*c2;
        Complex eq3 = 73 - (c1 - c2) / (c2-4);

        // Неявное преобразование комплексного числа
        // в число с плавающей точкой
        float real = c1;

        // Явное преобразование числа с плавающей точкой в комплексное
        // (требует явного приведения)
        Complex c3 = (Complex) 34;

        // Выводим комплексные числа c1 и c2
        Console.WriteLine ("Complex number 1:  {0}", c1);
        Console.WriteLine ("Complex number 2: {0}\n", c2);

        // Выводим результаты арифметических операций
        Console.WriteLine ("Result of equation 1: {0}", eq1);
        Console.WriteLine ("Result of equation 2: {0}", eq2);
        Console.WriteLine ("Result of equation 3: {0}", eq3);
        Console.WriteLine ();

        // Выводим результаты преобразований
        Console.WriteLine ("Complex-to-float conversion: {0}", real);
        Console.WriteLine ("float-to-Complex conversion: {0}", c3);
        Console.ReadLine ();
    }
}

Если бы оно было записано с помощью вызовов методов, получилось бы нечто вроде этого:

Листинг 5.

Complex C3 = C1.multiply(C2).add((C1.minus(13)).multiply(5));

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

Работая с реальными значениями, хранящимися в стеке, можно подумать, что перегрузка операторов не скажется на общей скорости выполнения. Теоретически, она вообще не должна влиять на производительность, и хороший компилятор C++ подтвердил бы правильность этой теории. Однако из-за ограничений текущей версии JIT компилятор не выполняет некоторые оптимизации, вследствие чего код может работать чуть медленнее, чем ожидалось. Эта проблема будет решена в следующей версии JIT компилятора.

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

Многомерные массивы

C# поддерживает три типа массивов: одномерные (single-dimensional arrays), неровные (jagged arrays) и прямоугольные (rectangular arrays).

Как и в C, одномерные массивы индексируются от 0 и содержат элементы, хранящиеся в памяти последовательно. Такие массивы неявно обрабатываются специальным набором MSIL инструкций (newarr, ldelem, stelem и др.). Это делает одномерные массивы особенно привлекательными, поскольку компилятор может оптимизировать их самыми разнообразными способами.

Хотя одномерные массивы являются частью практически любого вычислительного приложения, численные расчеты немыслимы без эффективных многомерных массивов. Многомерные массивы в C# бывают двух видов: неровные и прямоугольные.

Неровный массив — это одномерный массив одномерных массивов. Неровный массив удобнее всего представить как вертикальный столбец, каждый слот которого указывает на другое место в памяти, где хранится какой-либо одномерный массив («строка»). Учитывая, что одномерные массивы в C# оптимизируются на уровне MSIL, массив одномерных массивов должен быть очень эффективен. Однако это почти полностью зависит от способа доступа к массиву. Если код доступа часто нарушает локальность, затраты на переходы по указателям в памяти могут стать весьма велики. Другое преимущество неровных массивов (или недостаток в зависимости от ваших критериев) в том, что их «строки» могут быть разной длины, откуда и появился термин «неровный массив». Из-за этого при каждом обращении к другой «строке» проводится множество проверок границ. Однако в текущей версии исполняющей среды индексация неровных массивов оптимизируется лучше, чем при доступе к прямоугольным массивам.

Понимая недостатки неровных массивов, разработчики C# решили включить в спецификацию языка C подобные многомерные массивы, предназначенные для критичных к быстродействию приложений и для пользователей, которые просто предпочитают прямоугольные массивы неровным. В отличие от неровных массивов прямоугольный массив хранится в смежных областях памяти, а не рассредоточен по куче. К сожалению, для прямоугольных массивов не предусмотрен отдельный набор MSIL инструкций, вместо них для доступа к элементам массивов используется пара вспомогательных методов set/get. Эти методы не создают издержек в период выполнения (по крайней мере для двух- и трехмерных массивов), поскольку JIT компилятор считает их внутренними. В сущности, код генерируется со множеством оптимизаций, в том числе без проверок выхода индексов за границы.

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

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

Листинг 6. Тестирование последовательной и диагональной выборки

using System;

namespace PerfCounter {

    /// <summary>
    /// Этот класс предоставляет "секундомер" для приложений,
    /// требующих точного измерения времени
    /// </summary>
    public class Counter
    {

        [System.Runtime.InteropServices.DllImport("KERNEL32")]
        private static extern bool QueryPerformanceCounter(ref
            long lpPerformanceCount);

        [System.Runtime.InteropServices.DllImport("KERNEL32")]
        private static extern bool
            QueryPerformanceFrequency(ref long lpFrequency);

        long totalCount = 0;
        long startCount = 0;
        long stopCount  = 0;
        long freq      = 0;

        public void Start()
        {
            startCount = 0;
            QueryPerformanceCounter(ref startCount);
        }

        public void Stop()
        {
            stopCount = 0;
            QueryPerformanceCounter(ref stopCount);
            totalCount += stopCount - startCount;
        }

        public void Reset()
        {
            totalCount = 0;
        }

        public float TotalSeconds
        {
            get
            {
                freq = 0;
                QueryPerformanceFrequency(ref freq);
                return((float) totalCount / (float) freq);
            }
        }

        public double MFlops(double total_flops)
        {
            return (total_flops / (1e6 * TotalSeconds));
        }

        public override string ToString()
        {
            return String.Format("{0:F3} seconds", TotalSeconds);
        }
    }
}

using System;
using PerfCounter;

namespace BenchArrays
{
    /// <summary>
    /// Тестирование последовательного и диагонального доступа
    /// к неровным и прямоугольным массивам
    /// </summary>
    class TestArrays
    {

        [STAThread]
        static void Main(string[] args)
        {
            int loopCounter = 1000;
            int dim      = 1000;
            double temp;

            // Объявляем неровный двухмерный массив
            double[][] arrJagged = new double[dim][];

            // Объявляем прямоугольный двухмерный массив
            double[,] arrRect = new double[dim, dim];

            /* Создаем экземпляры массивов и инициализируем их */
            for (int i=0; i<arrJagged.Length; i++)
            {
                arrJagged[i] = new double[dim];
                for (int j=0; j<arrJagged[i].Length; j++)
                {
                    arrJagged[i][j] = arrRect[i, j] = i*j;
                }
            }

            Counter counter = new Counter();

            // ЦИКЛ 1.
            // Измеряем время последовательного доступа
            // к прямоугольному массиву.
            counter.Reset();
            counter.Start();
            Console.WriteLine("Starting loop 1...");
            for(int i=0; i<loopCounter; i++)
            {
                for(int j=0; j<dim; j++)
                {
                    for(int k=0; k<dim; k++)
                    {
                        temp = arrRect[j, k];
                    }
                }
            }
            counter.Stop();
            Console.WriteLine("Time for rect sequential access:
                            {0}", counter);
            Console.WriteLine();

            // ЦИКЛ 2.
            // Измеряем время диагонального доступа
            // к прямоугольному массиву.
            Console.WriteLine("Starting loop 2...");
            counter.Reset();
            counter.Start();
            for(int i=0; i<loopCounter; i++)
            {
                for(int j=0; j<dim; j++)
                {
                    for(int k=0; k<dim; k++)
                    {
                        temp = arrRect[k, k];
                    }
                }
            }
            counter.Stop();
            Console.WriteLine("Time for rect diagonal access:
                            {0}", counter);
            Console.WriteLine();

            // ЦИКЛ 3.
            // Измеряем время последовательного доступа
            // к неровному массиву.
            counter.Reset();
            counter.Start();
            Console.WriteLine("Starting loop 3...");
            for(int i=0; i<loopCounter; i++)
            {
                for(int j=0; j<arrJagged.Length; j++)
                {
                    for(int k=0; k<arrJagged[j].Length; k++)
                    {
                        temp = arrJagged[j][k];
                    }
                }
            }
            counter.Stop();
            Console.WriteLine("Time for jagged sequential
                            access: {0}", counter);
            Console.WriteLine();

            // ЦИКЛ 4.
            // Измеряем время диагонального доступа
            // к неровному массиву.
            counter.Reset();
            counter.Start();
            Console.WriteLine("Starting loop 4...");
            for(int i=0; i<loopCounter; i++)
            {
                for(int j=0; j<arrJagged.Length; j++)
                {
                    for(int k=0; k<arrJagged[j].Length; k++)
                    {
                        temp = arrJagged[k][k];
                    }
                }
            }
            counter.Stop();
            Console.WriteLine("Time for jagged diagonal access:
                            {0}", counter);
            Console.WriteLine("End Of Benchmark.");
            Console.ReadLine();
        }
    }
}

На рис. 2 показаны результаты, полученные при выполнении тестов из листинга 6. Как видите, последовательная выборка из довольно больших массивов для обоих типов массивов дает сравнимые результаты, тогда как диагональная выборка из неровного массива выполняется примерно в восемь раз медленнее, чем из прямоугольного.

Изображение GIF  Рис. 2. Последовательная и диагональная выборка

Хотя прямоугольные массивы обычно превосходят неровные в отношении структуризации и производительности, возможны случаи, где неровные массивы оптимальны. Если вашему приложению не нужны сортируемые, переупорядочиваемые, разделенные (partitioned), разреженные или большие массивы, то неровные массивы вполне подойдут. Однако заметьте: это утверждение верно для большинства приложений, но неприменимо для библиотечного кода. Контекст, в котором кто-то другой будет использовать ваш библиотечный код, зачастую неизвестен, и это может стать причиной плохой производительности систем.

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

В таких ситуациях переходите на прямоугольные массивы, и общая производительность значительно возрастет. Для примера в коде листинга 7 и 8 тестируется производительность двух разных реализаций приложения, которое перемножает пару больших матриц. Каждое из этих приложений использует класс счетчика производительности, показанный в листинге 6. Для представления матриц в коде листинга 7 используются двухмерные неровные массивы, а в листинге 8 — прямоугольные. Каждое приложение выводит время своей работы и число MFLOPS. Этот простой тест призван показать, что код, использующий прямоугольные массивы, примерно в восемь—девять раз быстрее эквивалентного кода с неровными массивами.

Листинг 7. Перемножение матриц с применением неровных массивов

using System;
using System.Diagnostics;
using PerfCounter;

namespace BenchJaggedMatrix
{
    /// <summary>
    /// Перемножение матриц с использованием неровных массивов
    /// </summary>
    class MatrixMul
    {

        [STAThread]
        static void Main(string[] args)
        {
            int i, n;

            // Объявляем неровные матрицы
            double[][] MatrixA, MatrixB, MatrixC;
            Random r = new Random(50);
            n = 1000;

            MatrixA = new double[n][];
            MatrixB = new double[n][];
            MatrixC = new double[n][];

            /* Создаем экземпляры массивов и инициализируем их */
            for (i=0; i<MatrixA.Length; i++) {
                MatrixA[i] = new double[n];
                MatrixB[i] = new double[n];
                MatrixC[i] = new double[n];
                for (int j=0; j<MatrixA[i].Length; j++) {
                    MatrixA[i][j]=(double)r.Next(50);
                    MatrixB[i][j]=(double)r.Next(50);
                }
            }
            Counter counter = new Counter();

            /* Вызов и замер времени выполнения Matdot */
            Console.WriteLine("Starting counter...");
            counter.Reset();
            counter.Start();
            Matdot(MatrixA, MatrixB, MatrixC);  // вызов MatDot
            counter.Stop();

            Console.WriteLine("Time taken: {0}", counter);
            Console.WriteLine("Obtained {0:F2} MFlops",
                            counter.MFlops(2*n*n*n));
            Console.ReadLine();
        }

        public static void Matdot(double [][]a, double [][]b,
                                double [][]c)
        {
            int i,j,k;
            double tmp;

            for (i=0; i<a.Length; i++)
            {
                for (j=0; j<c[i].Length; j++)
                {
                    tmp = c[i][j];
                    for (k=0; k<b[i].Length; k++)
                    {
                        tmp += a[i][k]*b[k][j];
                    }
                    c[i][j]=tmp;
                }
            }
        }
    }
}

Листинг 8. Перемножение матриц с применением прямоугольных массивов

using System;
using System.Diagnostics;
using PerfCounter;

namespace BenchRectMatrix
{
    /// <summary>
    /// Перемножение матриц с использованием прямоугольных массивов
    /// </summary>
    class MatrixMul
    {

        [STAThread]
        static void Main(string[] args)
        {
            int i, n;

            // Объявляем прямоугольные матрицы
            double[,] MatrixA, MatrixB, MatrixC;
            Random r = new Random(50);
            n = 1000;

            MatrixA = new double[n,n];
            MatrixB = new double[n,n];
            MatrixC = new double[n,n];

            /* Инициализируем случайными значениями */
            for (i=0; i<n; i++)
            {
                for (int j=0; j<n; j++)
                {
                    MatrixA[i,j]=(double)r.Next(50);
                    MatrixB[i,j]=(double)r.Next(50);
                }
            }

            Counter counter = new Counter();

            /* Вызов и замер времени выполнения Matdot */
            Console.WriteLine("Starting counter...");
            counter.Reset();
            counter.Start();
            Matdot(n, MatrixA, MatrixB, MatrixC); // вызов MatDot
            counter.Stop();

            Console.WriteLine("Time taken: {0}", counter);
            Console.WriteLine("Obtained {0:F2} MFlops",
                            counter.MFlops(2*n*n*n));
            Console.ReadLine();
        }

        public static void Matdot(int n, double [,]a, double
                                [,]b, double [,]c)
        {
            int i,j,k;
            double tmp;

            for (i=0; i<n; i++)
            {
                for (j=0; j<n; j++)
                {
                    tmp = c[i,j];
                    for (k=0; k<n; k++)
                    {
                        tmp += a[i,k] * b[k,j];
                    }
                    c[i,j]=tmp;
                }
            }
        }
    }
}

Выполнение этих двух программ на моей тестовой машине дало результаты, показанные на рис. 3. Результаты могут быть еще лучше, если преобразовать многомерные массивы в одномерные. Если вас не заботит синтаксис, это тривиально: просто используйте один индекс как смещение. Например, ниже объявляется одномерный массив для использования вместо двухмерного:

Листинг 9.

double[] myArray = new double[ROW_DIM * COLUMN_DIM];

Изображение GIF  Рис. 3. Неровные массивы против прямоугольных

Для индексации элементов в таком массиве указывайте следующее смещение:

Листинг 10.

myArray[row * COLUMN_DIM + column];

Так будет несомненно быстрее, чем при использовании эквивалентного неровного или прямоугольного массива.

Если бы в языке не было прямоугольных массивов, было бы трудно назвать C# пригодным для научных расчетов. Надеюсь, что существующие сейчас в JIT компиляторе ограничения будут преодолены, но, как видно по результатам, показанным выше, даже с этими ограничениями прямоугольные массивы гораздо эффективнее, чем неровные.

Небезопасный подход

C# полон сюрпризов. Как разработчик с опытом работы на C/C++, я не хотел отказываться от указателей. Удивительно, но C# поддерживает указатели в стиле C. Наверное, вам интересно, как это делается, ведь сборщик мусора перемещает объекты и может переместить тот объект, на который ссылался ваш указатель. К счастью, исполняющая среда позволяет выйти из-под контроля сборщика мусора, фиксируя объекты в памяти, после чего они помечаются как неподлежащие перемещению при сборе мусора. Для тех, кто много работает с массивами это хорошая новость. Во-первых, к многомерным прямоугольным массивам можно легко обращаться так, будто они одномерные. Во-вторых, при доступе к элементу массива исполняющая среда выполняет проверку границ массива, чтобы убедиться в том, что вы не обращаетесь к памяти за пределами массива (однако есть несколько ситуаций, в которых JIT может проанализировать границы доступа и установить, что при каждом обращении проверка границ не нужна). Если вы уверены в своем коде, такая проверка лишь вносит нежелательные издержки.

Но есть одна ловушка. Код, использующий указатели, должен быть помечен как небезопасный (unsafe) и может запускаться только в полностью доверяемой среде (fully trusted environment), а это, к примеру, означает, что вы не сможете запустить такой код прямо из Интернета. Однако это не должно волновать научных программистов, которые запускают код в полностью доверяемой среде, если только не создают сетевое или общедоступное приложение.

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

Изображение GIF  Рис. 4. Быстродействие небезопасного кода

Взаимодействие языков

Многие разработчики тратили время и ресурсы на создание библиотек или решений на основе технологий и языков, предшествовавших .NET Framework. Хотя в переносе кода на C# много преимуществ, это не всегда приемлемо, если у вас уже есть протестированный код, на который можно опереться в дальнейшей работе. К счастью, благодаря спецификациям Common Language Specification (CLS) и Common Type System (CTS) в .NET встроена поддержка взаимодействия языков. Она позволяет существующему коду, написанному на любом CLS-совместимом языке, эффективно взаимодействовать с кодом на C#. В результате ваши любимые библиотеки для численных расчетов или код, написанный, скажем, на FORTRAN, можно интегрировать непосредственно в приложения на C#. Вы даже можете писать критичные к быстродействию части кода на неуправляемом C и использовать их в управляемом приложении на C#. .NET Framework обычно делает переходы между управляемым и неуправляемым кодом незаметными.

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

Эталонные тесты

Показанные выше результаты тестов были получены при выполнении комплекса эталонных тестов SciMark 2.0 на процессоре Pentium 4 с тактовой частотой 2.4 ГГц и 256 МБ оперативной памяти. Для сравнения были взяты результаты от C версии SciMark 2.0, которые представлены на рис. 5.

Изображение GIF  Рис. 5. Сравнение производительности C# и C

Следует отметить, что текущая C# версия SciMark использует двухмерные неровные массивы в большинстве своих ядер, таких как SOR, перемножение разреженных матриц (Sparse matmul) и LU. Очевидно, именно в этом причина того, что C# уступает C в этих трех ядрах и работает почти так же, как C в FFT. Возможно, переход на прямоугольные массивы дал бы значительно лучшие результаты.

Не стоит воспринимать эти результаты как окончательный вердикт по производительности двух языков. Поскольку тесты SciMark напрямую перенесены в C# из Java, эта реализация использует не самые оптимальные средства C#; поэтому результаты могут быть улучшены за счет таких альтернатив, как структуры, прямоугольные массивы и даже блоки небезопасного кода. На рис. 6 показаны результаты по отдельным тестам.

Изображение GIF  Рис. 6. Результаты SciMark

Заключение

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

Смотрите также…

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