Часть 1. Принципы разработки через тестирование

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

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

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

TDD и научные вычисления: совпадение не заключено на небесах?

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

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

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

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

Валидация и проверка

Райли и Клун отмечают (выделено мной):

Эффективное тестирование численного программного обеспечения требует комплексного набора оракулов […], а также надежных оценок неизбежных числовых ошибок […] На первый взгляд эти проблемы часто кажутся чрезвычайно сложными или даже непреодолимыми для реальных научных приложений. Однако мы утверждаем, что это распространенное мнение неверно и обусловлено (1) объединением проверки модели и проверки программного обеспечения и (2) общей тенденцией научного сообщества к разработке относительно крупнозернистых, крупных процедуры, которые объединяют многочисленные алгоритмические шаги.

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

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

Это важное различие относится не только к области научных вычислений. Любой практикующий TDD явно или неявно разрабатывает тесты, которые охватывают как проверку, так и проверку.

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

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

Предостережения по тестированию научного программного обеспечения

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

  • Начните с проверки допуска настолько точно, насколько позволяет наименее точный тип с плавающей запятой, используемый в вычислениях. Ваши тесты могут провалиться. Если они это сделают, ослабляйте точность на один десятичный знак за раз, пока они не пройдут. Если вы не можете получить хорошую точность (например, вам нужен допуск 10 ^ -2 для прохождения теста с использованием операций с плавающей запятой), у вас может быть ошибка.
  • Численная погрешность вообще растет с количеством операций. Когда это возможно, подтвердитеточность на основе знаний, специфичных для предметной области (например, методы Тейлора имеют явные остаточные члены, которые можно использовать в тестах, но такие ситуации встречаются редко).
  • По возможности отдавайте предпочтение абсолютным допускам и избегайте относительных допусков («точность») при сравнении значений, близких к нулю.
  • Нередки случаи, когда точные модульные тесты терпят неудачу при выполнении тестов тысячи раз на разных машинах. Если это происходит постоянно, либо точность слишком строгая, либо была введена ошибка. Последнее было гораздо более распространенным в моем опыте.

Тестирование новых методов

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

  • Создайте набор репрезентативных примеров, которые вы можете проверять автоматически или вручную.
  • Примеры должны быть репрезентативными. Это может включать выполнение ресурсоемких вычислительных задач. Поэтому важно отделиться от набора модульных тестов.
  • Запускайте эти примеры как можно чаще.

Случайное тестирование

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

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

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

Профилирование

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

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

  • Профильные единицы. Как и в случае с единицами тестирования, вы должны профилировать критически важные для производительности единицы кода. Модель передового опыта NVIDIA CUDA — Оценить, распараллелить, оптимизировать, развернуть (APOD). Единицы профилирования дают вам отличные возможности для оценки, если вы хотите перенести свой код на GPU.
  • Профилируйте, что важно в первую очередь. Ошибайтесь из соображений осторожности, но не профилируйте фрагменты кода, которые не будут выполняться повторно или оптимизация которых не приведет к большим результатам.
  • Профилируйте разнообразно. Профилируйте процессорное время, память и любые другие полезные показатели для приложения.
  • Обеспечение воспроизводимых сред для профилирования. Версии библиотек, нагрузка на ЦП и т. д.
  • Попробуйте профилировать внутри вашего модульного тестирования. Вам не нужно проваливать тесты, которые регрессируют, но вы должны, по крайней мере, отметить их.

Собираем все вместе: пошаговая модель

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

Цикл реализации

  1. Соберите требования. В каком контексте вы будете использовать свой метод? Подумайте о том, какую функциональность он должен предоставлять, насколько он должен быть гибким, входными и выходными данными, автономным или частью какой-то более крупной кодовой базы. Подумайте, что он должен делать сейчас и что вы, возможно, захотите, чтобы он делал в будущем. На этом этапе легко преждевременно оптимизировать, поэтому помните: будь проще, тупица и тебе это не понадобится.
  2. Нарисуйте эскиз. Создайте шаблон, либо код, либо диаграммы, определяющие дизайн, который удовлетворяет вышеуказанным требованиям.
  3. Проведите начальные тесты. Вы находитесь на шаге 3 и вам не терпится начать программировать. Сделайте глубокий вдох! Вы начнете кодировать, но не свой метод/функцию. На этом этапе вы пишете очень простые тесты. Вроде, совсем маленький. Начните с простых проверочных тестов и переходите к базовым проверочным тестам. Для проверочных тестов я предлагаю с самого начала максимально использовать аналитические оракулы. Если это невозможно, пропустите их.
  4. Реализуйте свою альфа-версию. У вас есть свои тесты (проверки), вы можете начать реализовывать код, чтобы начать удовлетворять их, не опасаясь быть (очень) неправильным. Эта первая реализация не обязательно должна быть самой быстрой, но она должна быть правильной (проверка)! Мой совет: начните с простой реализации, используя стандартные библиотеки. Использование стандартных библиотек значительно снижает риск неправильной реализации, поскольку использует их набор тестов.
  5. Создайте библиотеку Oracle. Я не могу не подчеркнуть, насколько это важно! На этом этапе вы хотите установить заслуживающие доверия оракулы, на которые вы всегда можете положиться в будущих реализациях и/или изменениях ваших методов. Эта часть обычно отсутствует в традиционном TDD, но имеет первостепенное значение в научном программном обеспечении. Это гарантирует, что ваши результаты будут не только численно правильными, но и защитит новые и, возможно, другие реализации от научно неточных. Это нормально переключаться между реализацией и исследовательскими сценариями для создания ваших проверочных оракулов, но избегайте одновременного написания тестов.
  6. Пересмотреть тесты. Вооружившись вашими оракулами, которые вы старательно сохранили, напишите еще несколько проверочных модульных тестов. Опять же, избегайте переходов между реализацией и тестами.
  7. Внедрить профилирование. Настройте профилирование внутри и вне ваших модульных тестов. Вы вернетесь к этому, когда у вас будет первая итерация.

Цикл оптимизации

  1. Оптимизировать. Теперь вы хотите сделать эту функцию настолько быстрой, насколько это необходимо для вашего приложения. Вооружившись своими тестами и профилировщиками, вы можете раскрыть свои знания в области научных вычислений, чтобы сделать их быстрыми.
  2. Повторно реализовать. Здесь вы рассматриваете новые реализации, например, с использованием библиотек аппаратного ускорения, таких как графические процессоры, распределенные вычисления и т. д. Я предлагаю APOD от NVIDIA (Оценить, распараллелить, оптимизировать, развернуть) в качестве хорошей методологии оптимизации. Можно вернуться к циклу реализации, но теперь у вас всегда куча оракулов и тестов. Если вы ожидаете, что функциональность изменится, см. ниже.

Цикл нового метода

  1. Реализовать новый метод. Следуйте циклу реализации, как если бы у вас не было никаких оракулов до шага 6 включительно.
  2. Проверка по предыдущим проверенным оракулам. После этапа построения оракула вы можете использовать свои предыдущие примеры оракулов из предыдущей реализации, чтобы убедиться, что новый в чем-то «лучше» предыдущего. Этот шаг является ключевым в разработке алгоритмов и методов, устойчивых к различным данным. Он часто используется в промышленности, чтобы гарантировать, что новые алгоритмы работают в различных соответствующих случаях.

Следующие шаги

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

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