00:00:00 Введение в доклад о выгрузке на диск
00:00:34 Обработка данных ритейлеров и ограничения памяти
00:02:13 Решение для постоянного хранения и сравнение стоимости
00:04:07 Сравнение скорости диска и памяти
00:05:10 Ограничения техник разделения и потоковой передачи
00:06:16 Важность упорядоченных данных и оптимального размера чтения
00:07:40 Худший случай для чтения данных
00:08:57 Влияние памяти машины на выполнение программы
00:10:49 Техники выгрузки на диск и использование памяти
00:12:59 Объяснение кода и реализация в .NET
00:15:06 Контроль над выделением памяти и последствия
00:16:18 Страница отображения в память и файлы отображения в память
00:18:24 Карты памяти для чтения и записи и инструменты производительности системы
00:20:04 Использование виртуальной памяти и страниц отображения в память
00:22:08 Работа с большими файлами и 64-битными указателями
00:24:00 Использование span для загрузки из отображенной в память памяти
00:26:03 Копирование данных и использование структуры для чтения целых чисел
00:28:06 Создание span из указателя и менеджера памяти
00:30:27 Создание экземпляра менеджера памяти
00:31:05 Реализация программы выгрузки на диск и отображения памяти
00:33:34 Версия с отображением в память предпочтительнее для производительности
00:35:22 Стратегия буферизации потока файлов и ограничения
00:37:03 Стратегия отображения одного большого файла
00:39:30 Разделение памяти между несколькими большими файлами
00:40:21 Заключение и приглашение к вопросам

Резюме

Чтобы обработать больше данных, чем может поместиться в памяти, программы могут выгружать часть этих данных на более медленное, но большее хранилище, такое как диски NVMe. С помощью комбинации двух довольно неизвестных функций .NET (файлы с отображением в память и менеджеры памяти), это можно сделать из C# с минимальными или без каких-либо потерь производительности. Этот доклад, прочитанный на Варшавских IT-днях 2023 года, подробно рассказывает о том, как это работает, и обсуждает, как открытый источник пакета NuGet Lokad.ScratchSpace скрывает большую часть этих деталей от разработчиков.

Расширенное резюме

В обстоятельной лекции Виктор Николе, технический директор Lokad, углубляется в тонкости выгрузки на диск в .NET, техники, которая позволяет обрабатывать большие наборы данных, превышающие объем памяти типичного компьютера. Николе опирается на свой обширный опыт работы со сложными наборами данных в области количественной оптимизации цепи поставок в Lokad, предоставляя практический пример ритейлера с сотней тысяч продуктов в 100 местах. Это приводит к набору данных из 10 миллиардов записей при учете ежедневных точек данных за три года, что потребует 37 гигабайт памяти для хранения одного значения с плавающей точкой для каждой записи, что значительно превышает возможности типичного настольного компьютера.

Николе предлагает использовать постоянное хранилище, такое как NVMe SSD, как более экономичную альтернативу памяти. Он сравнивает стоимость памяти и SSD-хранилища, отмечая, что за стоимость 18 гигабайт памяти можно купить один терабайт SSD-хранилища. Он также обсуждает компромисс между производительностью, отмечая, что чтение с диска в шесть раз медленнее, чем чтение из памяти.

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

Затем Николе представляет техники выгрузки на диск как решение ограничений подхода “помещается в память”. Эти техники распределяют данные между памятью и постоянным хранилищем динамически, используя больше памяти, когда она доступна для более быстрой работы, и замедляясь, чтобы использовать меньше памяти, когда ее меньше. Он объясняет, что техники выгрузки на диск используют столько памяти, сколько возможно, и начинают сбрасывать данные на диск только тогда, когда они исчерпывают память. Это делает их лучше в реагировании на наличие большего или меньшего объема памяти, чем изначально ожидалось.

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

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

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

Затем Николе обсуждает системный инструмент “Performance Tool”, который показывает текущее использование физической памяти. Зеленым цветом обозначена память, которая была непосредственно назначена процессу, синим - кэш страниц, а измененные страницы в середине - это те, которые должны быть точной копией диска, но содержат изменения в памяти.

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

Затем Николе объясняет основные шаги создания файла, отображенного на память, в .NET, которые сначала создают файл, отображенный на память, из файла на диске, а затем создают представление-доступ. Оба держатся отдельно, потому что .NET должен иметь дело с случаем 32-битного процесса. В случае 64-битного процесса можно создать один представитель доступа, который загружает весь файл.

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

Затем Николе обсуждает, как использовать интервал для загрузки из отображенной на память памяти в управляемую память .NET. Например, если нужно прочитать строку, можно использовать множество API, ориентированных на интервалы. Николе объясняет использование API, ориентированных на интервалы, таких как MemoryMarshal.Read, который может прочитать целое число с начала интервала. Он также упоминает функцию Encoding.GetString, которая может загрузить из интервала байтов в строку.

Он дальше объясняет, что эти операции выполняются на интервалах, которые представляют собой секцию данных, которые могут быть на диске, а не в памяти. Операционная система обрабатывает загрузку данных в память при первом доступе. Николе приводит пример последовательности значений с плавающей точкой, которые нужно загрузить в массив float. Он объясняет использование MemoryMarshal.Read для чтения размера, выделения массива значений с плавающей точкой этого размера и использование MemoryMarshal.Cast для преобразования интервала байтов в интервал значений с плавающей точкой.

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

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

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

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

Он предлагает разделить память на несколько больших файлов, никогда не записывать в одну и ту же память дважды и удалять файлы как можно скорее. Николе заканчивает, поделившись тем, что в производстве в Lokad они используют Lokad scratch space с определенными настройками: файлы имеют по 16 гигабайт, на каждом диске 100 файлов, и каждый L32VM имеет четыре диска, представляющих немного больше 6 терабайт пространства для вытеснения для каждой виртуальной машины.

Полный текст

Слайд 1

Виктор Николе: Здравствуйте и добро пожаловать на эту лекцию о вытеснении на диск в .NET.

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

Эта лекция основана на моем опыте работы в Lokad. Мы занимаемся количественной оптимизацией цепочек поставок.

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

Итак, мы делаем много довольно сложной обработки.

Слайд 4

Давайте посмотрим на типичный пример. У ритейлера может быть порядка ста тысяч продуктов.

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

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

Предполагая, что мы сохраняем только одну точку данных за каждый день и смотрим только на три года в прошлом, это означает около 1000 дней. Умножьте все это вместе, и наш набор данных будет иметь 10 миллиардов записей.

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

Слайд 10

И одного значения с плавающей точкой недостаточно для проведения какого-либо анализа.

Более подходящим числом было бы 20, и даже тогда мы прилагаем очень большие усилия, чтобы сделать объем памяти маленьким. Даже тогда мы смотрим на около 745 гигабайт использования памяти.

Это помещается в облачные машины, если они достаточно большие, около семи тысяч долларов в месяц. Так что это вполне доступно, но также довольно расточительно.

Слайд 11

Как вы могли догадаться из названия этой лекции, решение - использовать вместо этого постоянное хранилище, которое медленнее, но дешевле памяти.

В наши дни вы можете приобрести NVMe SSD-хранилище за около 5 центов за гигабайт. NVMe SSD - это примерно самый быстрый вид постоянного хранилища, который вы можете легко получить в наши дни.

В сравнении, один гигабайт оперативной памяти стоит 275 долларов. Это примерно в 55 раз больше.

Слайд 14

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

Слайд 15

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

За примерно две тысячи долларов в месяц вы получаете почти 8 терабайтов постоянного хранилища.

Справа - M32ms, часть серии, оптимизированной для памяти, и за более чем в два с половиной раза большую стоимость вы получаете всего 875 гигабайт оперативной памяти.

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

Слайд 16

Что насчет производительности? Ну, чтение из памяти идет со скоростью около 21 гигабайта в секунду. Чтение с NVMe SSD идет со скоростью около 3,5 гигабайта в секунду.

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

Важная часть здесь - это лишь порядок величины разницы между двумя. Чтение с диска в шесть раз медленнее, чем чтение из памяти.

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

Слайд 19

Довольно известная техника использования дискового пространства в качестве альтернативы памяти - это разделение.

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

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

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

Слайд 21

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

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

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

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

У потоковой передачи есть свои ограничения. Для того чтобы она была производительной, данные на диске должны быть упорядочены или правильно выровнены.

Слайд 26

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

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

Слайд 28

Итак, оптимально, когда данные, которые мы читаем, кратны полкилобайту и выровнены по границам секторов.

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

Слайд 30

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

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

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

Слайд 31

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

Слайд 32

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

Итак, если вы читаете одно значение с плавающей точкой с каждой страницы, вы уменьшили свою производительность в 1024 раза.

По этой причине очень важно обеспечить чтение данных из постоянного хранилища большими последовательными партиями.

Слайд 33

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

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

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

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

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

Слайд 38

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

Слайд 39

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

Слайд 41

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

Слайд 42

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

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

Слайд 43

Это ограничение подхода “помещается в памяти”, где распределение набора данных между памятью и постоянным хранилищем полностью определяется структурой набора данных и самим алгоритмом.

Он не учитывает фактическое количество доступной памяти. То, что делают техники переноса на диск, - это динамическое распределение. Так что, если доступно больше памяти, они будут использовать больше памяти для ускорения работы.

Слайд 46

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

Слайд 47

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

Это делает их гораздо лучше в реагировании на наличие большего или меньшего количества памяти, чем изначально ожидалось. Техники “переноса на диск” разделяют набор данных на две секции. Горячий раздел предполагается всегда находиться в памяти, поэтому всегда безопасно в терминах производительности обращаться к нему с произвольными шаблонами доступа. У него, конечно, будет максимальный бюджет, возможно, что-то вроде 8 гигабайт на ЦПУ на типичной облачной машине.

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

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

Slide 50

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

slide 51

Для горячего раздела я буду использовать обычные объекты .NET и проблему, которую мы рассмотрим в обычной программе .NET. Для холодного раздела я буду использовать это, называемое классом ссылок. Этот класс сохраняет ссылку на значение, которое помещается в холодное хранилище, и это значение может быть установлено в null, когда оно больше не находится в памяти. У него есть функция переноса, которая берет значение из памяти и записывает его в хранилище, а затем обнуляет ссылку, что позволит сборщику мусора .NET вернуть эту память, когда он почувствует давление.

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

Slide 53

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

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

Slide 56

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

Slide 57

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

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

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

Slide 63

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

Slide 66

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

Slide 70

Если программа вносит изменения в память на отображенной странице, то операционная система в какой-то момент в будущем сохранит содержимое этой страницы обратно на диск. И, конечно, можно попросить, чтобы это произошло в определенный момент, используя функции, такие как flush на Windows. Инструмент “Системный монитор производительности” имеет это приятное окно, которое показывает текущее использование физической памяти.

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

Slide 71

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

Slide 73

Если вас интересует, есть отличный ресурс Вячеслава Бирюкова о том, что происходит в кэше страниц Linux. Используя виртуальную память, давайте сделаем нашу вторую попытку. Сработает ли это на этот раз? Теперь мы решаем, что холодная секция будет состоять полностью из отображенных в память страниц. Так что все они должны быть сначала на диске.

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

Slide 76

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

Slide 77

Все эти техники применимы к .NET и присутствуют в открытом проекте Lokad Scratch Space. И большая часть следующего кода основана на том, как делает это пакет NuGet.

Slide 78

Во-первых, как мы создадим файл с отображением в память в .NET? Отображение памяти существует с .NET Framework 4, примерно 13 лет назад. Он довольно хорошо документирован в интернете, и исходный код полностью доступен на GitHub.

Slide 80

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

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

В нашем случае мы будем работать с 64-битными указателями. Так что мы можем просто создать одно представление, которое загружает весь файл. И теперь я использую AcquirePointer, чтобы получить указатель на первые байты этого отображенного в память диапазона памяти. Когда я закончу работать с указателем, я могу просто освободить его. Работа с указателями в .NET небезопасна. Это требует добавления ключевого слова unsafe везде, и это может взорваться, если вы попытаетесь получить доступ к памяти за пределами разрешенного диапазона.

Slide 81

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

Slide 83

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

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

Slide 84

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

Например, допустим, у нас есть строка, которую мы хотим прочитать. Она будет расположена в файле, отображенном в памяти, как размер, за которым следует полезная нагрузка байтов, закодированная в UTF-8, и мы хотим загрузить из этого строку .NET.

Slide 86

Ну, есть много API, которые ориентированы на промежутки, которые мы можем использовать. Например, MemoryMarshal.Read может прочитать целое число с начала промежутка. Затем, используя этот размер, я могу попросить функцию Encoding.GetString загрузить из промежутка байтов в строку.

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

Slide 87

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

Slide 88

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

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

Slide 89

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

Slide 90

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

Slide 91

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

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

Slide 92

У Span есть одно основное ограничение, которое заключается в том, что его нельзя использовать в качестве члена класса и, соответственно, его также нельзя использовать в качестве локальной переменной в асинхронном методе.

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

Slide 94

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

Slide 95

Вкратце, нам нужно создать MemoryManager. MemoryManager используется внутри Memory, когда ему нужно сделать что-то более сложное, чем просто указание на раздел массива.

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

Slide 97

Конструктор класса довольно прост.

Slide 98

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

Slide 99

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

Slide 100

Мы переопределяем функцию GetSpan, которая делает всю магию. Она просто создает промежуток, используя адрес и длину.

Slide 101

Есть два других метода, которые нужно реализовать в MemoryManager. Один из них - Pin. Он используется средой выполнения в случае, когда память должна быть сохранена в одном месте на короткий срок. Мы добавляем ссылку и возвращаем MemoryHandle, который указывает на правильное место и также ссылается на текущий объект как на прикрепляемый.

Slide 102

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

Slide 103

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

Slide 104

Итак, этого достаточно для реализации нашей программы переноса на диск. Есть еще один вопрос, почему использовать отображение памяти, когда мы могли бы использовать FileStream? В конце концов, FileStream - это очевидный выбор при доступе к данным, которые находятся на диске, и его использование достаточно хорошо документировано. Чтение массива значений с плавающей точкой, например, вам нужен FileStream и BinaryReader, обернутый вокруг FileStream. Вы устанавливаете позицию на смещение, где присутствуют данные, вы читаете Int32 с помощью ридера, выделяете массив с плавающей точкой, а затем преобразуете его в промежуток байтов с помощью MemoryMarshal.Cast.

Slide 106

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

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

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

По этой причине использование версии с отображением памяти предпочтительнее с точки зрения производительности. В конце концов, FileStream - это очевидный выбор для доступа к данным, которые находятся на диске, и его использование очень хорошо документировано. Например, чтобы прочитать массив значений с плавающей точкой, вам нужен FileStream, BinaryReader. Вы устанавливаете позицию FileStream на смещение, где данные присутствуют в файле, вы читаете Int32, чтобы получить размер, выделяете массив с плавающей точкой, преобразуете его в промежуток байтов с помощью MemoryMarshal.Cast и передаете его в перегрузку FileStream.Read, которая хочет промежуток байтов в качестве своего места назначения для чтения. И это также использует кэш страниц. Вместо того чтобы страницы были связаны с процессом, их держит сама операционная система, и она просто загружает с диска в кэш страниц и копирует из кэша страниц в память процесса, так же, как мы делали с версией отображения памяти.

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

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

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

Однако остается один последний вопрос, на который нам нужно ответить. В конце концов, когда вы отображаете в памяти вещи, вы не отображаете диск в памяти, вы отображаете файлы на диске в памяти. Теперь вопрос в том, сколько файлов мы собираемся выделить? Какого они будут размера? И как мы будем перебирать эти файлы, когда мы выделяем и освобождаем память?

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

Slide 111

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

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

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

Slide 113

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

Slide 114

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

Slide 115

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

Slide 116

В производстве в Lokad, на типичной производственной VM, мы используем Lokad scratch space со следующими настройками: каждый файл имеет 16 гигабайт, на каждом диске 100 файлов, и каждый L32VM имеет четыре диска. В общей сложности это представляет немного больше 6 терабайт пространства для каждой VM.

Slide 117

Вот и все на сегодня. Пожалуйста, свяжитесь с нами, если у вас есть какие-либо вопросы или комментарии, и спасибо за просмотр.