(C) Анимация компании Seagate
(C) Анимация компании Seagate

1. Введение

После разработки виртуальной машины и компилятора в рамках хобби прошел год и захотелось попробовать реализовать ёмкий по алгоритмам проект по системному программированию.

Каждый разработчик "кровавого" enterprise в своей работе использует СУБД (SQL/NoSQL) и меня всегда искренне интересовало как они устроены в самом сердце, на самом низком уровне. Почитав документацию и исходный код SQLite и MongoDB, про используемые в индексах и интерпретаторах запросов алгоритмы, осознал, что несмотря на широкую распространенность и некую привычность, системы управления базами данных (СУБД) - это сложные программные продукты, реализация которых не всем под силу. Отлично - как раз то, что мне надо. С мотивацией разобрались, перейдем к делу.

Итак, для начала хорошо бы сформулировать высокоуровневую спецификацию требований. Boson - это легкая, встраиваемая документоориентированная база данных на С/С++:

  1. Простой и понятный API для работы с базой данных.

  2. Быстрый поиск документов по ID на основе B+ Tree индексов.

  3. Хранилище коллекций документов формата ключ/значение (JSON).

  4. Поддержка LRU-cache для ускорения файловых операций ввода/вывода.

  5. Хранение данных в одном файле, без временных файлов и настроек.

  6. Кроссплатформенный "чистый" C/C++ и отсутствие внешних зависимостей.

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

Архитектура слоёв базы данных Boson
Архитектура слоёв базы данных Boson

Начну поэтапную реализацию проекта снизу вверх. Первое - кэширование ввода/вывода.

2. Кэширование файлового ввода/вывода (CachedFileIO)

Все мы знаем, что скорость I/O накопителя (HDD/SSD) в сотни раз медленнее, чем работа с оперативной памятью (RAM), а RAM в сотни раз медленнее I/O кэш вашего процессора (CPU). При этом достоверно известно, что практически все приложения в "реальном мире" имеют некую степень локальности обращения к данным.

В ряде исследований есть статистика, что кэш размером 10-15% от размера базы данных даёт в среднем более 95% попаданий в кэш (cache hits), а соотношение операций чтения/записи примерно соотносятся как 70%/30% Поэтому, для производительности кэширование (многоуровневая организация памяти) - это оправданная стратегия. Производительность CachedFileIO будем оценивать в сравнении со стандартными функциями STDIO (fread, fwrite) - прирост более 50% будем считать успехом.

Будем считать атомарным блоком чтения/записи при работе с физическим накопителем (storage device) и кэшем в оперативной памяти (memory cache) массив байт фиксированного размера (8Кб). Назовем его Страница (Page). Предполагается, что операции чтения/записи выровненные по границам физических секторов диска (HDD) или блокам (SSD) выполняются значительно быстрее. Аналогичная ситуация и с оперативной памятью. Кэшем (cache) будем считать загруженные в оперативную память, наиболее часто/недавно использовавшиеся страницы файла.

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

  • Хранить в кэше наиболее используемые страницы и удалять самые старые. То есть использовать алгоритм кэширования Least Recent Used (LRU), но таким образом, чтобы размер кэша не влиял на его производительность, он может иметь как сотни, так и сотни тысяч страниц, а именно, отвечать следующим требованиям:

    • Сложность поиска необходимой страницы в кэше - O(1).

    • Сложность вставки страницы в кэш - O(1).

    • Сложность удаления страницы из кэша - O(1).

  • При записи использовать стратегию Fetch-Before-Write (FBW). Если запись осуществляется в страницу, которая не была ранее загружена, нужно предварительно загрузить её в кэш. Это необходимо, чтобы при изменении части страницы, мы не затерли то, что было в остальной части.

  • Реализация должна быть максимально "cache-friendly" для CPU. Желательно использовать непрерывные обычные массивы, чтобы при попадании в кэш, она вероятнее всего уже была в одном из уровней кэша CPU (находилась рядом).

  • Выравнивать запросы операций ввода/вывода на границы файловой страницы (8192 байта). Операции чтения/записи в файл выровненные по границам страниц (сектора на HDD или блока на SSD) выполняются значительно быстрее.

  • Реализация должна быть 64-битной (не иметь ограничений на файлы более 4Гб).

  • Иметь очень простой API, инкапсулирующий детали реализации.

Примечание: на текущем этапе, для упрощения, мы осознанно не включаем в требования по блокировке файлов (file lock), так как без специфичных для ОС API этого не сделать. Механизмы для безопасного многопоточного использования (thread safe) также сделаем позже.

Как некогда говорил А. Привалов, на когда-то независимом канале РБК, "вещи не такие сложные, как кажутся на первый взгляд. И не такие простые как на второй...". Поэтому начнем со структур данных, которые необходимы для реализации стратегии кэша LRU/FBW.

Чтобы поиск, вставка, удаление из/в кэш по сложности была O(1), нам нужно объединить свойства таких структур данных как хэш-таблица и связанный список. Первый нам даёт нужную скорость поиска, а второй вставки и удаления. Если в Java есть стандартный шаблон LinkedHashMap, то в стандартной библиотеке C++ такого нет. И прекрасно - реализуем сами.

Вот иллюстрация необходимой нам структуры данных: хэш-таблица и связанный список
Вот иллюстрация необходимой нам структуры данных: хэш-таблица и связанный список

Логика работы алгоритма LRU Cache при чтении будет следующая:

  1. Пользователь CachedFileIO делает вызов метода чтения данных (read, readPage). Мы считаем какие страницы файла затрагивает операция, пробегаемся по списку запрошенных страниц.

  2. Для каждой запрошенной страницы проверяем, есть ли она в кэше - ищем номер файловой страницы в хэш-таблице. Если страница нашлась в хэш-таблице - это попадание кэш, перемещаем её в начало списка как наиболее "свежую", копируем запрошенные данные в буфер пользователя.

  3. Если не находим номер страницы в хэш-таблице, то берем свободную страницу кэша. Если свободных страниц кэша нет, то выгружаем (если была изменена - помечена DIRTY) на диск наиболее "старую" (последнюю в списке) страницу в кэше, удаляем из списка и хэш-таблицы. В конце, загружаем запрошенную страницу с диска, вставляя её номер в начало списка (List) и добавляя ссылку на узел списка в хэш-таблицу. Помечаем страницу как CLEAN ("чистая"), обозначая, что в неё после загрузки с диска изменения не вносились.

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

Логика алгоритма при записи будет следующая (Fetch-Before-Write):

  1. Пользователь CachedFileIO делает вызов метода записи данных (write, writePage). Мы считаем какие страницы файла затрагивает операция, пробегаемся по списку запрошенных страниц.

  2. Для каждой изменяемой страницы проверяем, есть ли она в кэше - ищем номер файловой страницы в хэш-таблице. Если страница нашлась в хэш-таблице - это попадание кэш, перемещаем её в начало списка как наиболее "свежую", записываем пользовательские данные в страницы кэша. Помечаем их как DIRTY (измененные).

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

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

Иллюстрация алгоритма: показаны состояния кэша слева направо, сверху указан запрашиваемый элемент.
Иллюстрация алгоритма: показаны состояния кэша слева направо, сверху указан запрашиваемый элемент.

Класс CachedFileIO будет иметь следующее объявление:

#pragma once

#define _ITERATOR_DEBUG_LEVEL 0                  // MSVC debug performance fix

#include <cstdio>
#include <cstring>
#include <cstdint>
#include <unordered_map>

namespace Boson {

	//-------------------------------------------------------------------------
	constexpr uint64_t PAGE_SIZE      = 8192;         // 8192 bytes page size
	constexpr uint64_t MINIMAL_CACHE  = 256 * 1024;   // 256Kb minimal cache
	constexpr uint64_t DEFAULT_CACHE  = 1*1024*1024;  // 1Mb default cache
	constexpr uint64_t NOT_FOUND      = -1;           // "Not found" signature
	//-------------------------------------------------------------------------

	typedef enum {                              // Cache Page State
		CLEAN = 0,                              // Page has not been changed
		DIRTY = 1                               // Cache page is rewritten
	} PageState;

	typedef struct {
		uint8_t data[PAGE_SIZE];
	} CachePageData;

	class alignas(64) CachePage {               // Align to CPU cache line
	public:
		uint64_t  filePageNo;                   // Page number in file
		PageState state;                        // Current page state
		uint64_t  availableDataLength;          // Available amount of data
		uint8_t*  data;                         // Pointer to data (payload)
		std::list<CachePage*>::iterator it;     // Cache list node iterator
	};

	//-------------------------------------------------------------------------

	typedef                                     // Double linked list
		std::list<CachePage*>                   // of cached pages pointers
		CacheLinkedList;

	typedef                                     // Hashmap of cached pages
		std::unordered_map<size_t, CachePage*>  // File page No. -> CachePage*           
		CachedPagesMap;                         

	//-------------------------------------------------------------------------

	typedef enum {                              // CachedFileIO stats types
		TOTAL_REQUESTS,                         // Total requests to cache
		TOTAL_CACHE_MISSES,                     // Total number of cache misses
		TOTAL_CACHE_HITS,                       // Total number of cache hits
		TOTAL_BYTES_WRITTEN,                    // Total bytes written
		TOTAL_BYTES_READ,		                // Total bytes read
		TOTAL_WRITE_TIME_NS,                    // Total write time (ns)
		TOTAL_READ_TIME_NS,                     // Total read time (ns)
		CACHE_HITS_RATE,                        // Cache hits rate (0-100%)
		CACHE_MISSES_RATE,                      // Cache misses rate (0-100%)
		WRITE_THROUGHPUT,                       // Write throughput Mb/sec
		READ_THROUGHPUT                         // Read throughput Mb/sec
	} CachedFileStats;


	//-------------------------------------------------------------------------
	// Binary random access LRU cached file IO
	//-------------------------------------------------------------------------
	class CachedFileIO {
	public:
		CachedFileIO();
		~CachedFileIO();
		
		bool open(const char* path, size_t cache = DEFAULT_CACHE, bool readOnly = false);
		bool close();
		bool isOpen();

		size_t read(size_t position, void* dataBuffer, size_t length);
		size_t write(size_t position, const void* dataBuffer, size_t length);
		size_t readPage(size_t pageNo, void* userPageBuffer);
		size_t writePage(size_t pageNo, const void* userPageBuffer);
		size_t flush();

		void   resetStats();
		double getStats(CachedFileStats type);
		size_t getFileSize();
		size_t getCacheSize();
		size_t setCacheSize(size_t cacheSize);

	private:

		void       allocatePool(size_t pagesCount);
		void       releasePool();
		CachePage* allocatePage();
		CachePage* getFreeCachePage();                            
		CachePage* searchPageInCache(size_t filePageNo);
		CachePage* loadPageToCache(size_t filePageNo);
		bool       persistCachePage(CachePage* pageInfo);
		bool       clearCachePage(CachePage* pageInfo);		
				
		uint64_t        maxPagesCount;           // Maximum cache capacity (pages)
		uint64_t        pageCounter;             // Allocated pages counter
				
		uint64_t        totalBytesRead;          // Total bytes read
		uint64_t        totalBytesWritten;       // Total bytes written
		uint64_t        totalReadDuration;       // Time of read operations (ns)
		uint64_t        totalWriteDuration;      // Time of write operations (ns)
		uint64_t        cacheRequests;           // Cache requests counter
		uint64_t        cacheMisses;             // Cache misses counter

		std::FILE*      fileHandler;             // OS file handler
		bool            readOnly;                // Read only flag
		CachedPagesMap  cacheMap;                // Cached pages map 
		CacheLinkedList cacheList;               // Cached pages double linked list
		CachePage*      cachePageInfoPool;       // Cache pages info memory pool
		CachePageData*  cachePageDataPool;       // Cache pages data memory pool
	};




}

Реализацию класса можно посмотреть тут CachedFileIO.cpp.

3. Тестирование CachedFileIO

Так как у меня на компе стоит только SSD для тестирования также купил HDD подключаемый через USB, чтобы сравнивать на разных типах устройств. По настоящему я удивился, когда увидел что скорость их работы одинаковая (!). Но потом понял в чем дело.

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

В ряде исследований есть статистика, что кэш размером 10-15% от размера базы данных даёт в среднем более 95% попаданий в кэш (cache hits), а соотношение операций чтения/записи примерно соотносятся как 70%/30%

Для тестирования предположим, что запросы данных будут нормально распределены: σ =4% от размера базы и локализованы в каком то одном месте - для CachedFileIO без разницы даже если в нескольких, лишь бы локализованы. Примерно вот так:

Далее методом Box Muller`а будем генерировать нормально распределенные псевдослучайны числа, чтобы запрашивать разные участки файла (но нормально распределенные):

/**
*
*  @brief  Box Muller Method
*  @return normal distributed random number
*
*/
double CachedFileIOTest::randNormal(double mean, double stddev)
{
	static double n2 = 0.0;
	static int n2_cached = 0;
	if (!n2_cached) {
		double x, y, r;
		do {
			x = 2.0 * rand() / RAND_MAX - 1.0;
			y = 2.0 * rand() / RAND_MAX - 1.0;
			r = x * x + y * y;
		} while (r == 0.0 || r > 1.0);
		double d = sqrt(-2.0 * log(r) / r);
		double n1 = x * d;
		n2 = y * d;
		double result = n1 * stddev + mean;
		n2_cached = 1;
		return result;
	}
	else {
		n2_cached = 0;
		return n2 * stddev + mean;
	}
}

Будем делать тестирование с миллионом итераций для каждого теста, мелкими порциями данных сравнивая результаты производительности работы CachedFileIO (read, write, readPage, writePage) и STDIO (fread, fwrite). Вот тут сам бенчмарк на коленках без использования каких либо фреймворков. И вот что получилось:

В release сборке сравнение производительности CachedFileIO и STDIO при многократных запусках с разными параметрами дало следующие результаты:

  • 50%-97% попадания в кэш даёт от 50%-600% прироста производительности.

  • 35%-49% попадания в кэш даёт от 12%-36% прироста производительности

  • 1%-25% попаданий в кэш дают падение производительности от 5%-20%

Значит всё сработало как надо или, точнее, как ожидалось.

Наблюдение: почему-то в компиляторе MSVC в режиме debug очень "тормозят" итераторы и мешают измерениям. В 20-30 раз в сравнении с release. Приходилось отключать их отладку _ITERATOR_DEBUG_LEVEL.

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

До встречи!

Комментарии (41)


  1. edo1h
    01.01.2023 09:29
    +7

    Например, потому что в операционной системе эффективно работает кэш файловой системы,

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


    1. Tzimie
      01.01.2023 12:58
      +2

      И MSSQL


  1. e_u_g
    01.01.2023 11:12
    +4

    почему-то в компиляторе MSVC в режиме debug очень "тормозят" итераторы

    Непроверенная догадка: может компилятор добавляет в сгенерированный код дополнительные проверки, чтобы ловить всякие внештатные ситуации и облегчать отладку?


    1. Basheyev Автор
      01.01.2023 11:56

      Поэтому "тормозят" в кавычках. Да, отладка итераторов вещь полезная, но в моем проекте ненужная.


    1. slonopotamus
      01.01.2023 14:14
      -1

      Зачем гадать, если можно просто взять и посмотреть в исходники?


      1. Basheyev Автор
        01.01.2023 14:46
        +6

        Тут нет гаданий. По исходникам и нашел же, что bounds checking создают overhead. Так как я их проверяю сам, поэтому они мне не нужны.


  1. tzlom
    01.01.2023 12:10
    +3

    А если маппить на память, будет быстрее или нет?

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


    1. Basheyev Автор
      01.01.2023 12:15
      +1

      Да, спасибо про лицензию. Да, однозначно mmap и/или флаг O_DIRECT в open могут помочь, но это все же вызовы OS, а не C++. Поэтому на текущем этапе избегал (это часть требований).


      1. tzlom
        01.01.2023 12:39
        +2

        Ну как бы да, а с другой стороны вы уже используете _fseeki64 так что код все равно прибит к одной ОС :)

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


        1. vassabi
          01.01.2023 13:09

          ну, _fseeki64 можно будет заменить на обычный, это гораздо менее ОС-зависимая функция (базовые открыть, закрыть, считать, записать файл сидят в либс https://www.gnu.org/software/libc/manual/2.36/html_mono/libc.html#Portable-Positioning )


      1. ostinru
        01.01.2023 22:58
        +3

        Вот тут чуваки в соавторстве с Andy Pablo говорят что MMAP для баз данных - плохая идея:
        Are You Sure You Want to Use MMAP in Your Database Management System? https://www.cidrdb.org/cidr2022/papers/p13-crotty.pdf

        Поэтому, только если IO_DIRECT


  1. slonopotamus
    01.01.2023 14:11

    скорость I/O накопителя (HDD/SSD) в сотни раз медленнее, чем работа с оперативной памятью (RAM)

    А вот и нет. Скорость линейного чтения из DDR4 порядка 20-25GB/s. При этом вполне себе доступны NVMe SSD со скоростью чтения 6-7GB/s.


    1. tupenbor
      01.01.2023 14:34
      +4

      "DDR4 порядка 20-25GB/s"
      С одного канала, одной планки. Обычно каналов, планок, в количестве от 2 до 8 и больше, соответственно и скорость в 2-8 раза больше. И если сравнивать с каким медленным SSD/SATA, то разница вполне может на сотню потянуть ;)


      1. slonopotamus
        01.01.2023 14:47

        одной планки

        Ну так и SSD можно несколько штук поставить.


        1. tzlom
          01.01.2023 16:17

          И завести в raid, иначе скорость не вырастет.


          1. borovinskiy
            01.01.2023 20:04

            А 7 ГБ/c с SSD в типичных нагрузках вы и сейчас не увидите. Увидите 70-100Мб/c в однопоток. Ну а ваши базы данных в однопоток и пишут и писать в 50 потоков, чтобы нагрузить SSD параллельной записью, просто не способны.


            1. blind_oracle
              01.01.2023 23:15
              +2

              SSD одним потоком последовательно вполне пишут свои 7ГБ\с. Вот со случайным доступом уже всё несколько грустнее.


              1. borovinskiy
                02.01.2023 00:24
                +1

                Ссылку на бенчмарк можно c 7ГБ в один поток последовательной записи?


                1. cdriper
                  03.01.2023 10:49

                  да пожалуйста

                  https://www.thessdreview.com/our-reviews/nvme/samsung-990pro-gen-4-ssd-series-review-samsung-reigns-true-yet-again/3/

                  общедоступная информация, которая находится за минуту


                  1. borovinskiy
                    03.01.2023 17:08

                    4k 1Q 1T: 106 Мб чтение и 365 Мб запись. И это на файле в 1ГБ, то есть не пробивая SLC-кеш на запись, а как пробьете, скорость на запись упадет в разы.

                    Если же говорить про 1M Q8 T1, то это фактически пишут пачками по 8 Мб и в SSD действует внутренний распараллеливающий механизм, то есть это для ОС запись последовательная, а по факту она параллельная и на файле в 1ГБ это вообще тест ОЗУ SSD. А надо писать так, чтобы если уж такие объемы записи, чтобы пробить SLC кеш диска и тогда у вас скорость тоже драматическим образом упадет.

                    Поэтому дайте нормальный бенчмарк на файле с 200 Гб или такого размера, чтобы пробить SLC-кеш конкретного SSD (и уж тем более его ОЗУ). Лучше всего fio.


                    1. edo1h
                      04.01.2023 05:50

                      и на файле в 1ГБ это вообще тест ОЗУ SSD

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


                      Поэтому дайте нормальный бенчмарк на файле с 200 Гб или такого размера, чтобы пробить SLC-кеш конкретного SSD (и уж тем более его ОЗУ).

                      держите. не 7, конечно, но всё равно немало
                      root@debian:~# fio -ioengine=libaio -sync=0 -direct=1 -name=test -bs=64k -iodepth=1 -rw=write -runtime=300 -filename=/dev/nvme3n1 -numjobs=1
                      test: (g=0): rw=write, bs=(R) 64.0KiB-64.0KiB, (W) 64.0KiB-64.0KiB, (T) 64.0KiB-64.0KiB, ioengine=libaio, iodepth=1
                      fio-3.25
                      Starting 1 process
                      Jobs: 1 (f=1): [W(1)][100.0%][w=2216MiB/s][w=35.5k IOPS][eta 00m:00s]
                      test: (groupid=0, jobs=1): err= 0: pid=665472: Wed Jan  4 02:45:51 2023
                        write: IOPS=35.4k, BW=2213MiB/s (2321MB/s)(648GiB/300001msec); 0 zone resets
                          slat (nsec): min=2359, max=53196, avg=2517.88, stdev=116.96
                          clat (nsec): min=430, max=2151.0k, avg=25371.04, stdev=5988.31
                           lat (usec): min=24, max=2153, avg=27.92, stdev= 5.99
                          clat percentiles (usec):
                           |  1.00th=[   25],  5.00th=[   25], 10.00th=[   25], 20.00th=[   25],
                           | 30.00th=[   25], 40.00th=[   25], 50.00th=[   25], 60.00th=[   26],
                           | 70.00th=[   26], 80.00th=[   27], 90.00th=[   27], 95.00th=[   28],
                           | 99.00th=[   29], 99.50th=[   29], 99.90th=[   36], 99.95th=[   36],
                           | 99.99th=[  108]
                         bw (  MiB/s): min= 2179, max= 2227, per=100.00%, avg=2214.44, stdev= 9.85, samples=599
                         iops        : min=34866, max=35644, avg=35430.98, stdev=157.65, samples=599
                        lat (nsec)   : 500=0.01%
                        lat (usec)   : 20=0.01%, 50=99.97%, 100=0.02%, 250=0.01%, 500=0.01%
                        lat (usec)   : 750=0.01%, 1000=0.01%
                        lat (msec)   : 2=0.01%, 4=0.01%
                        cpu          : usr=6.14%, sys=9.53%, ctx=10624294, majf=0, minf=14
                        IO depths    : 1=100.0%, 2=0.0%, 4=0.0%, 8=0.0%, 16=0.0%, 32=0.0%, >=64=0.0%
                           submit    : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
                           complete  : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
                           issued rwts: total=0,10624239,0,0 short=0,0,0,0 dropped=0,0,0,0
                           latency   : target=0, window=0, percentile=100.00%, depth=1
                      
                      Run status group 0 (all jobs):
                        WRITE: bw=2213MiB/s (2321MB/s), 2213MiB/s-2213MiB/s (2321MB/s-2321MB/s), io=648GiB (696GB), run=300001-300001msec
                      
                      Disk stats (read/write):
                        nvme3n1: ios=46/10617848, merge=0/0, ticks=2/273002, in_queue=273004, util=100.00%

                      pm9a3


                      на блоках 4К ожидаемо будет меньше (≈350), на блоках 1М — больше (≈3500).
                      и дело тут не только в распараллеливании записи со стороны накопителя, а и в накладных расходах на сисколы и т. п.


            1. PrinceKorwin
              02.01.2023 07:56

              Ну почему же. Данные могут писать в несколько потоков. А вот wal-журнал, да. В один поток.


              1. borovinskiy
                02.01.2023 09:55

                Но все равно раз запись двойная, быстрее чем в один поток в wal не будет.
                Да еще и частые fsync-и.


                1. edo1h
                  03.01.2023 12:44

                  Да еще и частые fsync-и

                  не совсем в тему, но БД, всё-таки, это обычно datacenter ssd, на которых штраф за fsync околонулевой.


                  1. borovinskiy
                    03.01.2023 18:05
                    +1

                    Сейчас нет данных под рукой, но по памяти штраф не нулевой даже у Optane. А есть бенч где нулевой?


        1. tupenbor
          01.01.2023 17:16

          А как вы думаете, как часто устанавливают дисковый raid при одной планке памяти. И ниже уже написали про latency, которая намного отличается.


    1. edo1h
      01.01.2023 14:42
      +5

      только префетч не всегда можно устроить, а latency отличается на три порядка (50-100 нс против 50+ мкс)


  1. masscry
    01.01.2023 18:31

    С новым годом!

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


    1. Basheyev Автор
      01.01.2023 18:39
      +2

      Согласен. Переучиваюсь на проекте с C и Java, на современный C++. Проскакивает местами


  1. Bober4eg
    01.01.2023 18:40
    +2

    Шикарная работа. Очень шикарная.

    А насчёт интерпретатора - не готов ответить на данный момент. Железо... Ограничения... Бюджеты...


  1. WASD1
    01.01.2023 20:59
    +1

    1. Отличная работа.
      А учитывая, что вы это делаете ради самообразования \ любви к искусству (программирования) - снимаю шляпу.

      ПС
      А транзакции планируются?
      Мне всегда казалось, что DBMS это что-то про гарантии консистентности данных (ну то есть по-простому начиная с транзакций).


    1. Basheyev Автор
      02.01.2023 03:51

      Спасибо! ACID - хотелось бы реализовать, но пока не разобрался как это делается в NoSQL.


  1. Kotofay
    01.01.2023 21:29
    +3

    А если вместо хэшмапы использовать битовые поля?
    Для поиска бит можно использовать либо интринсики либо быстрые алгоритмы, например такой:
    Индекс бита это номер страницы.
    Индекс * размер страницы => смещение в mmap файле.

    // возвращает размер _bits в 32 битных беззнаковых словах.
    int size( bool units = true ); 
    
    // получить индекс бита в 1
       int getMSB() {
          int r = -1;
    #ifndef MACHINE_INSTRUCTIONS
          const char lt[ 256 ] =
          {
    #define LT(n) n, n, n, n, n, n, n, n, n, n, n, n, n, n, n, n
             - 1, 0, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3,
             LT( 4 ), LT( 5 ), LT( 5 ), LT( 6 ), LT( 6 ), LT( 6 ), LT( 6 ),
             LT( 7 ), LT( 7 ), LT( 7 ), LT( 7 ), LT( 7 ), LT( 7 ), LT( 7 ), LT( 7 )
          };
          register quint32 t, tt, v;
          for( int i = size( true ) - 1; i >= 0; i-- ) {
             v = _bits[ i ];
             if( v ) {
                if( tt = v >> 16 ) {
                   r = ( t = tt >> 8 ) ? 24 + lt[ t ] : 16 + lt[ tt ];
                }
                else {
                   r = ( t = v >> 8 ) ? 8 + lt[ t ] : lt[ v ];
                }
                return ( i * 32 + r );
             }
          }
    #else
          register unsigned long index;
          register quint32 v;
          for( int i = size( true ) - 1; i >= 0; i-- ) {
             v = _bits[ i ];
             if( v ) {
                if( _BitScanReverse( &index, v ) ) {
                   return i * ( sizeof( quint32 ) * 8U ) + index;
                }
             }
          }
    #endif
          return r;
       }
    
       // количество установленных бит
       int count() {
          register quint32 v = 0, c = 0;
          register int r = 0, i = 0, sizeInWords = size( true );
    #ifndef MACHINE_INSTRUCTIONS
          for( ; i < sizeInWords; i++ ) {
             v = _bits[ i ];
             v = v - ( ( v >> 1 ) & 0x55555555 );
             v = ( v & 0x33333333 ) + ( ( v >> 2 ) & 0x33333333 );
             c = ( ( v + ( v >> 4 ) & 0xF0F0F0F ) * 0x1010101 ) >> 24;
             r += c;
          }
    #else
          Q_UNUSED( v );
          Q_UNUSED( c );
          for( ; i < sizeInWords; i++ ) {
             r += __popcnt( _bits[ i ] );
          }
    #endif
          return r;
       }
    
       // set bit
       // index >= 0 && < sizeInBits -- set bit by index
       // otherwise -- set all bits
       void set( int index = -1 ) {
          if( index >= 0 ) {
             if( index < size() )
                _bits[ index / ( sizeof( quint32 ) * 8U ) ] |= ( 1U << ( index % ( sizeof( quint32 ) * 8U ) ) );
          }
          else {
             _bits.fill( ~( 0U ) );
          }
       }
       // clear bit
       // index >= 0 && < sizeInBits -- clear bit by index
       // otherwise -- clear all bits
       void clear( int index = -1 ) {
          if( index >= 0 ) {
             if( index < size() ) {
                quint32 bit = ( 1U << ( index % ( sizeof( quint32 ) * 8U ) ) );
                int chunk = index / ( sizeof( quint32 ) * 8U );
                _bits[ chunk ] |= bit;
                _bits[ chunk ] ^= bit;
             }
          }
          else {
             _bits.fill( 0 );
          }
       }
    
       //index >= 0 && < sizeInBits -- is bit set by index?
       // otherwise -- is all bits set?
       bool isSet( int index = -1 ) {
          if( index >= 0 ) {
             if( index < size() )
                return _bits[ index / ( sizeof( quint32 ) * 8U ) ] & ( 1U << ( index % ( sizeof( quint32 ) * 8U ) ) );
          }
          else {
             for( int i = 0, sizeInWords = size( true ); i < sizeInWords; i++ ) {
                if( _bits[ i ] != ~( 0U ) ) {
                   return false;
                }
             }
             return true;
          }
          return false;
       }
    
       //index >= 0 && < sizeInBits -- is bit clear by index?
       // otherwise -- is all bits clear?
       bool isClear( int index = -1 ) {
          if( index >= 0 ) {
             if( index < size() )
                return !( _bits[ index / ( sizeof( quint32 ) * 8U ) ] & ( 1U << ( index % ( sizeof( quint32 ) * 8U ) ) ) );
          }
          else {
             for( int i = 0, sizeInWords = size( true ); i < sizeInWords; i++ ) {
                if( _bits[ i ] ) {
                   return false;
                }
             }
             return true;
          }
          return false;
       }


    1. Kotofay
      01.01.2023 22:11
      +2

      На всякий случай работающий код.


  1. Apoheliy
    02.01.2023 00:43
    +3

    Возможно, в операции read если position кратен PAGE_SIZE и length равна PAGE_SIZE, то вы будете искать/читать 2 страницы вместо 1.

    Возможно, вы очень оптимистичны, считая, что разница steady_timer time_point выдаёт наносекунды. Желательно использовать duration_cast (https://en.cppreference.com/w/cpp/chrono/duration/duration_cast);

    Для хобби, возможно, это некритично:

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

    гарантии на исключения вообще никакие. У вас bad_alloc может вылетать и ... ничего.


    1. Basheyev Автор
      02.01.2023 03:52
      +1

      Спасибо за советы! Про read страниц проверю, а вот про время duration_cast не знал. По исключениям подумаю как обрабатывать.


    1. Basheyev Автор
      03.01.2023 05:55

      Спасибо огромное за review! Действительно, страницы читались дважды при position кратном PAGE_SIZE и length равна PAGE_SIZE. Поправил. И касательно копирования - запретил. Обработку bad_alloc добавил.


  1. Zara6502
    03.01.2023 04:44
    -2

    в 2000 году был мне заказ сделать обработку данных на примере одной программы написанной очень крутым (для меня) программистом, который, по слухам, уехал в США и работал уже в Майкрософт.

    Я знал только Delphi и LocalBDE и поэтому делал реализацию на них. У него проект был на C+WinAPI+какой-то бинарный формат БД.

    Больше всего была разница в объемах файлов БД, у меня месяц работы выражался в 2-3 мегабайт (не гигабайт) данных, для транспортировки на одной дискете использовал ZIP, выгрузка сразу шла в архив при экспорте. А у гуру в прошлом проекте данные за год умещались в 200 килобайт и плохо паковались, подозреваю что он сам всё сжимал в какой-то LZ вариант. Тогда я понял, что мне не стать хорошим программистом XD (и не стал). А смотря на поделки современных разработчиков уверен что сделал бы отличную карьеру если бы пошел этим путём, ибо сегодня говнокодеры в почёте.


    1. edo1h
      03.01.2023 12:49

      что-то я сомневаюсь, что самописная БД в 2000 году — признак хорошего программиста


      1. Zara6502
        03.01.2023 17:20

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


      1. Maccimo
        04.01.2023 05:28

        Первая версия SQLite появилась как раз в 2000 году. До неё со встраивыми СУБД было как-то не очень. Кто-то писал в пачку *.dbf, кто-то (ICQ, например) использовал формат MS Access.