Slide 1

Slide 1 text

最速のread性能を目指すピュア C#製組み込みDB実装 @ハダシA / C# Kaigi 2026

Slide 2

Slide 2 text

About Speaker ハダシA / @hadashiA • フリーランスでエンジニアしてます • Webサービス / オンラインゲームの仕事とか • C#/Unity用のOSS活動する勢 • RubyKaigi 2026 にも登壇したよ

Slide 3

Slide 3 text

• C#で組み込みDBを実装したヨ

Slide 4

Slide 4 text

ふつうのクライアント・サーバー型DB 組み込みDB • ライブラリとしてアプリケーションに組み込む • いいかんじにメモリ使用量を抑えつつ、ファイ ルシステムに永続化されたでっかいデータを扱 う • Webブラウザの履歴・キャッシュはじめ実はユ ースケースは多い

Slide 5

Slide 5 text

一般のクラサバ型 DB

Slide 6

Slide 6 text

組み込みDB • ライブラリとしてアプリケーションに組み込む • いいかんじにメモリ使用量を抑えつつ、ファイルシステムに永続化されたでっかいデータを扱う • Webブラウザの履歴・キャッシュはじめ実はユースケースは多い

Slide 7

Slide 7 text

ちなみに分散DB

Slide 8

Slide 8 text

ちなみに分散DB ココだけみると、 さっきの組み込みDBの図と 似てる

Slide 9

Slide 9 text

ちなみに分散DB TiDBでは、こーいうとこに 組み込みDB(RocksDB) が使われている

Slide 10

Slide 10 text

hadashiA/DryDB • 読み取り専用の組み込みデータベース実装 • ピュア C#(ネイティブ依存なし) • B+Tree / クラスタリングインデックス / 非mmap、手動ページ単位 I/O • 機能: • キー検索・キー範囲検索・キー前方一致検索・件数カウント、セカンダリインデック ス(unique / non-unique)、複数テーブル、昇順・降順、64 KB 超の BLOB • sync/async 両対応 • C# で フィルタが書ける (E.g: 暗号化、圧縮など) • Unity サポート(AsyncReadManager + NativeArray) • CLIツール

Slide 11

Slide 11 text

Why readonly? • 書き込みがなくなると実装が圧倒的にシンプル • 書き込み競合を考える必要が(基本的には)全くない • 実行速度が圧倒的に速くなる • DB初期化時、けっこー時間をかけて最適化したレイアウトを構築 してもよい

Slide 12

Slide 12 text

Why readonly? • ゲームとかだと、運営側が用意した大量のデータをアプリに組み込んで出荷したい • いわゆるマスターデータ • よくある手法の問題点 • Unityアセット • ポータビリティが無い(Unity 外・サーバから読めない) • 動的配布が面倒 • 動的ロード/アンロード管理が面倒 • メインスレッド以外から読めない • MessagePack/JSON/protobuf を丸ごとデシリアライズ(MasterMemory 等): • 取得は最速 • いつの間にか「メモリに載る量だけをマスターデータとみなす」制約がでてくる ハイパー省メモリかつ高速なreadonly組み込みDBをUnityにぶちこもう https://hadashikick.land/tech/vkv-preview

Slide 13

Slide 13 text

Why readonly? • 組み込みDBなら… • 1バイナリで配布が簡単 • DB側側が自動で最大メモリ使用量を最適化してくれる

Slide 14

Slide 14 text

Why readonly? • 実はSQLiteにも immutableモードというのがある • Openした後は書き込めなくなるが、そんかわし速い

Slide 15

Slide 15 text

DryDB実行速度改善の歴史 ns / lookup(10k 行・固定キー) 40 37.6 30 25.1 24.8 18.4 20 17.1 ­25% 10 1.0.3 2026-05 PR#27 devirt · root pin PR#28 fetch-add refcount PR#30 key digest PR#31‒33 struct比較器 · SIMD窓 PR#35 ordinal 直索引 (別マシン・相対値)

Slide 16

Slide 16 text

最適化の道程 1 コピーしない

Slide 17

Slide 17 text

最適化の道程 2 仮想メソッド呼び出ししない (devirtualization)

Slide 18

Slide 18 text

最適化の道程 3 余計なメインメモリアクセスしない (キャッシュミスさせない) •ワークロードによって高速なメモリレイアウトやアルゴリズムは けっこー変わる •ハードウェアパフォーマンスイベントとか計測するとけっこーよい

Slide 19

Slide 19 text

最適化の道程 4 CPUの分岐予測ミスしない •分岐を消す 分岐予測ミスを消す

Slide 20

Slide 20 text

最適化の道程 5 無駄なキャッシュ追い出ししない

Slide 21

Slide 21 text

.drydbの構築 // Create DB var builder = new DatabaseBuilder { // The smallest unit of data loaded into memory PageSize = 4096, }; // Create table (string key - ascii comparer) var table1 = builder.CreateTable("items", KeyEncoding.Ascii); table1.Append("key1", "value1"u8.ToArray()); // value is any `Memory` table1.Append("key2", "value2"u8.ToArray()); table1.Append("key3", "value3"u8.ToArray()); table1.Append("key4", "value4"u8.ToArray()); // Build await builder.BuildToFileAsync("/path/to/bin.drydb");

Slide 22

Slide 22 text

.drydbファイル内にあるデータの検索 // ファイルとして保存されているDBを開く var database = await ReadOnlyDatabase.OpenAsync("/path/to/bin.drydb"); var table = database.GetTable("items"); // キーバリューストアぽいAPI using var result = await table.GetAsync("key1"u8);

Slide 23

Slide 23 text

ファイルの中身を検索する素朴なモデル var result = await table.GetAsync(“key1"u8);

Slide 24

Slide 24 text

ファイルの中身を検索する素朴なモデル var result = await table.GetAsync(“key1"u8); C# の値として扱いたいのはさらにその一部 ファイルの一部のみメモリにキャッシュ OS以下のレイヤーは暗黙裏に ファイルの内容を先読み&キャッシュ

Slide 25

Slide 25 text

ファイルの中身を検索する素朴なモデル var result = await table.GetAsync(“key1"u8); C# の値として扱いたいのはさらにその一部 ファイルの一部のみメモリにキャッシュ コピーだらけ ですやん OS以下のレイヤーは暗黙裏に ファイルの内容を先読みする

Slide 26

Slide 26 text

メモリマップトファイル(mmap)方式 • mmap(2) • ファイルの内容を連続したメモリ 空間にマッピングするOSの機能 • Linux / macOS / BSD / iOS / Android / Windows • ユーザーランドへのコピー削減! • I/Oがいつ発生するかは隠蔽され る…

Slide 27

Slide 27 text

メモリマップトファイル(mmap)方式 using System.IO.MemoryMappedFiles; var mmf = MemoryMappedFile.CreateFromFile( path, FileMode.Open, mapName: null, capacity: 0, MemoryMappedFileAccess.Read); var view = mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read); byte* basePtr = null; view.SafeMemoryMappedViewHandle.AcquirePointer(ref basePtr); ReadOnlySpan Page(long offset, int len) => MemoryMarshal.CreateReadOnlySpan( ref Unsafe.AsRef(basePtr + offset), len);

Slide 28

Slide 28 text

mmap方式の欠点 • I/O の発生点が見えない・止められない • C# だから async 化したいのに…… • キャッシュポリシーを選べない • OS の汎用 LRU はスキャン耐性が無く、索引ページの優先保持もできず、メモリ上限も不明 • 最大メモリ使用量をコントロールしずらい • 変換を挟めない • 圧縮・暗号化・チェックサムは「ディスク上とメモリ上でバイト列が違う」ことを要求するが、mmap は同一であることが前提 • TLB (Tlanslation Lookup Bu er) あふれ • 仮想メモリアドレス領域が大きくなると、仮想メモリアドレス → 物理アドレス 変換のためのテーブルがTLBにおさまらなくなり、 lookupコストが増大する ff Are You Sure You Want to Use MMAP in Your Database Management System? Andrew Crotty, Viktor Leis, Andy Pavlo ― CIDR 2022 https://db.cs.cmu.edu/mmap-cidr2022/

Slide 29

Slide 29 text

Mmap方式は不採用がトレンド • RocksDB → LevelDBをforkし、mmapを廃止 • The History of RocksDB • LevelDB → 初期実装は mmap 。後に移行。 • MongoDB → MMAPv1 エンジンを廃止して WiredTiger へ。 ff • SQLite → mmap モードを持つがデフォルトでo 。

Slide 30

Slide 30 text

けっきょく自前ページキャッシュ方式 using var result = await table.GetAsync(“key1"u8);

Slide 31

Slide 31 text

System.IO.RandomAccess var pageLength = Unsafe.ReadUnaligned(ref GetArrayDataReference(lengthBuffer)); var bytesRead = 0; var destination = MemoryPool.Shared.Rent(pageLength); while (bytesRead < pageLength) { var buffer = destination.Memory[bytesRead..(pageLength - bytesRead)]; n = await RandomAccess.ReadAsync( handle, buffer, position + bytesRead, cancellationToken); if (n == 0) { throw new EndOfStreamException(); } bytesRead += n; }

Slide 32

Slide 32 text

DryDB.PageSlice public readonly struct PageSlice(IPageEntry entry, int start, int length) : IDisposable { public IPageEntry Page => entry; public int Start => start; public int Length => length; public ReadOnlySpan Span { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => Page.Memory.Span.Slice(start, length); } public ReadOnlyMemory Memory { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => Page.Memory.Slice(start, length); } } public void Dispose() { entry.Release(); } // return new PageSlice(lease.Take(), valueOffset, valueLength)

Slide 33

Slide 33 text

DryDB.PageSlice public readonly struct PageSlice(IPageEntry entry, int start, int length) : IDisposable { public IPageEntry Page => entry; public int Start => start; public int Length => length; public ReadOnlySpan Span { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => Page.Memory.Span.Slice(start, length); } 参照カウントでuse after freeは public ReadOnlyMemory Memory 防ぐ必要が… { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => Page.Memory.Slice(start, length); } } public void Dispose() { entry.Release(); } // return new PageSlice(lease.Take(), valueOffset, valueLength)

Slide 34

Slide 34 text

DryDBの自前ページキャッシュ方式 • .NET から扱える低レベルファイル I/OのAPIを駆使しつつ、 ff • C#なのでSystem.Bu ersの仕組みでコピー抑制をがんばる

Slide 35

Slide 35 text

カーネルのページキャッシュも積極的に減らすには? 手段 macOS Linux Windows 特徴 fcntl(fd, F̲NOCACHE, 1) fd 単位で以後の読みがキャッシ ュを(ほぼ)残さない。 O̲DIRECT キャッシュ完全バイパス。アラ インメントされたバッファが必 要 FILE̲FLAG̲NO̲BUFFERING

Slide 36

Slide 36 text

カーネルのページキャッシュも積極的に減らすには? static int Fcntl(SafeFileHandle handle, int cmd, long arg) { var addedRef = false; try { handle.DangerousAddRef(ref addedRef); var fd = (int)handle.DangerousGetHandle(); return fcntl(fd, cmd, arg, arg, arg, arg, arg, arg, arg); } finally Linuxの場合はC# の これがfd { if (addedRef) { handle.DangerousRelease(); } } }

Slide 37

Slide 37 text

カーネルのページキャッシュサイズを積極的に減らすAPI 手段 macOS Linux Windows fcntl(fd, F̲NOCACHE, 1) O̲DIRECT 特徴 fd 単位で以後の読みがキャッシ ュを(ほぼ)残さない。 キャッシュ完全バイパス。アラ インメントされたバッファが必 要 FILE̲FLAG̲NO̲BUFFERING I/O発生時にキャッ シュが効かなくなる トレードオフ

Slide 38

Slide 38 text

Unityで使えないヨ!

Slide 39

Slide 39 text

DryDBはC#で書かれているから、Unity独自路線に追従できるヨ! • .NETランタイム → System.IO.RandomAccess • Unity → Unity.IO.LowLevel.Unsafe.AsyncReadManager • Unityの独自路線C# API とプラガブルに交換可能

Slide 40

Slide 40 text

とあるinterface経由メソッド呼び出し… public interface IKeyEncoding { int Compare(ReadOnlySpan a, ReadOnlySpan b); } sealed class TreeWalker { // interface型をフィールドに保持 readonly IKeyEncoding keyEncoding; // … // 探索ループ // … // ★probe毎にinterface dispatchが発生 var compared = keyEncoding.Compare(entryKey, key); // … } // …

Slide 41

Slide 41 text

Dynamic dispatchのコスト • ヒープを辿った先のMethodTableにあるメソッドにアクセスする必要がある • インライン化が阻害される

Slide 42

Slide 42 text

JITがdynamic dispatchを最適化できるパターン • (a): 静的に決定できる具象型 • (b): Dynamic PGO の guarded devirtualizationが発動した場合

Slide 43

Slide 43 text

DryDB.IKeyEncoding • 静的に解決できない • バイナリに焼かれた情報によって動的に具象型が決まる • テーブル毎に具象型がバラバラになりえるので、JITが支配的な1つのFast Passを選べるわけではない • Guard分岐と、Slow Pass本体は残る

Slide 44

Slide 44 text

型パラメータで確実にdevirtualization // struct型パラメーターでgenerics化 sealed class TreeWalker { // interface型をフィールドに保持 readonly IKeyEncoding comparer; sealed class TreeWalker : TreeWalker where TComparer : struct, IKeyComparer { // structのまま保持 TComparer comparer; // diffensive-copy回避のため非readonly // … // … // ループ内 // ループ内 // … // … // ★probe毎にinterface dispatchが発生 var compared = comparer(entryKey, key); // ★ var compared = comparer.Compare(entryKey, key); // … } // … } // …

Slide 45

Slide 45 text

型パラメータで確実にdevirtualization // struct型パラメーターでgenerics化 sealed class TreeWalker { // interface型をフィールドに保持 readonly IKeyEncoding keyEncoding; sealed class TreeWalker : TreeWalker where TComparer : struct, IKeyComparer { // structのまま保持 Rustっぽいコードだね TComparer comparer; // diffensive-copy回避のため非readonly // … // … // ループ内 // ループ内 // … // … // ★probe毎にinterface dispatchが発生 var compared = keyEncoding.Compare(entryKey, key); // ★ var compared = comparer.Compare(entryKey, key); // … } // … } // …

Slide 46

Slide 46 text

型パラメータで確実にdevirtualization 修正前 修正後 sub add ldr w10, w8, w9 ; mid = min + (max-min)/2 w10, w9, w10, ASR #1 x11, [x3, x11] ; digest ロード movz x11, #0x508 movk x11, #0x964 LSL #16 movk x11, #1 LSL #32 ldr xip0, [x11] ; stub から飛び先をロード cmp x11, x4 ; 比較 blr ; 間接呼び出し blo G_M000_IG06 ; 分岐 xip0 インライン展開された ※macOSなのでarm64。DOTNET̲JitDisasm調べ

Slide 47

Slide 47 text

B+Tree

Slide 48

Slide 48 text

B+Tree このノード単位で メモリに読み込み いちノードに値 がいくつ入るかは、 ノードのサイズの設定次第

Slide 49

Slide 49 text

最初期の素朴なノードのレイアウト

Slide 50

Slide 50 text

素朴なレイアウトの問題点 • 問題1: 二分探索のprobeの度にメタ一回、payload一回のメモリアクセス • 問題2: アドレスを行ったり来たりするため、CPUキャッシュミスのリスク • 問題3: アドレスを行ったり来たりするため、SIMD化しずらい

Slide 51

Slide 51 text

素朴なレイアウトの問題点 Andrew Kelley - Practical DOD ̶ https://vimeo.com/649009599

Slide 52

Slide 52 text

Digestの導入 • 固定長 8バイトの 検索のためだけの値をあらかじめ先頭に構築 • 固定長なので読み込みが高速になる • 二分探索時にメモリのあちこちを見る必要がなくなり、CPUキャッシュ効率 改善の期待 & SIMD化ビリティの向上

Slide 53

Slide 53 text

Digestの導入 2. ヒットしたインデックスに対応する payloadの位置をここをみて特定 1. ココを読むだけで 二分探索できる 3. 完全なキーの等値比較 は最後

Slide 54

Slide 54 text

Digestを使った lower bound検索 static int LowerBound(ReadOnlySpan digests, ulong target) { var min = 0; var max = digests.Length; while (min < max) { var mid = min + ((max - min) >> 1); if (digests[mid] < target) min = mid + 1; else max = mid; } return min; }

Slide 55

Slide 55 text

Digestを使った lower bound検索 + SIMD static int LowerBound(ReadOnlySpan digests, ulong target) { // 残り32要素までは普通に二分探索 var min = 0; var max = digests.Length; while (max - min > 32) { var mid = min + ((max - min) >> 1); if (digests[mid] < target) min = mid + 1; else max = mid; } ラスト32要素のみSIMDで線形探索 どこまでSIMD化するかは匙加減 // ラスト32要素はSIMDで力業! var start = max - 32; var targetVec = Vector128.Create(target); var acc0 = Vector128.Zero; var acc1 = Vector128.Zero; var acc2 = Vector128.Zero; var acc3 = Vector128.Zero; for (var i = start; i < max; i += 8) { acc0 -= Vector128.LessThan(LoadDigests(digests, i), targetVec); acc1 -= Vector128.LessThan(LoadDigests(digests, i + 2), targetVec); acc2 -= Vector128.LessThan(LoadDigests(digests, i + 4), targetVec); acc3 -= Vector128.LessThan(LoadDigests(digests, i + 6), targetVec); } var acc = (acc0 + acc1) + (acc2 + acc3); return start + (int)(acc.GetElement(0) + acc.GetElement(1)); }

Slide 56

Slide 56 text

Digest = 可変長キーの値を、順序比較するための固定長64bit整数値に変換したもの • どんな64bitになっている必要があるか? • → ulongとして比較した場合に、元のキーと同じ順序になってさえいればそれでよい • 64bitより長いキーはどうなるか? • → Digestを二分探索したあと、実際のキーを見に行く

Slide 57

Slide 57 text

Digest = 可変長キーの値を、順序比較するための固定長64bit整数値に変換したもの encoding digest の作り方 (ulongとして比較した場合に順序が保持される値) ascii 先頭 8 バイトをビッグエンディアンとして読み込み (8バイト不足分は0埋め) Ulid 先頭 8 バイトをビッグエンディアンとして読み込み i64 符号ビットを反転 uuidv7 先頭 8バイト を、Guid.CompareTo のフィールド比較順(̲a, ̲b, ̲c)に並べ直し

Slide 58

Slide 58 text

実際に速くなった! しかし… • メモリレイアウトや、データの分布、ヒット率などの組み合わせによって、 非直感的に結果が変化してしまう • 実行速度の計測だけでは、なにが起きてるかわかりずらい :thinking̲face:

Slide 59

Slide 59 text

PMU (Performance Metrics Unit) static class Kpc { const string Kperf = "/System/Library/PrivateFrameworks/kperf.framework/kperf"; const string KperfData = "/System/Library/PrivateFrameworks/kperfdata.framework/kperfdata"; public const int MaxCounters = 32; // ---- kperf: kernel PMU control (root required for kpc_force_all_ctrs_set) ---macOSの場合、kperfて [DllImport(Kperf)] public static extern int kpc_force_all_ctrs_set(int val); [DllImport(Kperf)] public static unsafe extern int kpc_set_config(uint classes, ulong* config); のがあるヨ [DllImport(Kperf)] public static extern int kpc_set_counting(uint classes); [DllImport(Kperf)] public static extern int kpc_set_thread_counting(uint classes); [DllImport(Kperf)] public static unsafe extern int kpc_get_thread_counters(uint tid, uint bufCount, ulong* buf); // ---- kperfdata: event database (/usr/share/kpep/*.plist) and register mapping ---[DllImport(KperfData)] public static unsafe extern int kpep_db_create(byte* name, out IntPtr db); [DllImport(KperfData)] public static extern int kpep_config_create(IntPtr db, out IntPtr cfg); [DllImport(KperfData)] public static extern int kpep_config_force_counters(IntPtr cfg); [DllImport(KperfData)] public static unsafe extern int kpep_db_event(IntPtr db, byte* name, out IntPtr ev); [DllImport(KperfData)] public static unsafe extern int kpep_config_add_event(IntPtr cfg, ref IntPtr ev, uint flag, uint* err); [DllImport(KperfData)] public static unsafe extern int kpep_config_kpc(IntPtr cfg, ulong* buf, nuint bufSizeBytes); [DllImport(KperfData)] public static extern int kpep_config_kpc_count(IntPtr cfg, out nuint count); [DllImport(KperfData)] public static extern int kpep_config_kpc_classes(IntPtr cfg, out uint classes); [DllImport(KperfData)] public static unsafe extern int kpep_config_kpc_map(IntPtr cfg, nuint* buf, nuint bufSizeBytes); }

Slide 60

Slide 60 text

PMU (Performance Metrics Unit) var before = Pmc.Read(); for (var m = 0; m < N; m++) { seed = RunBatch(table, p, seed); } var delta = Pmc.Read() - before; もう一度データ収集して 前後比較 該当スレッドの イベント発生回数を収集 ベンチマークコード実行

Slide 61

Slide 61 text

Digest導入前後のハードウェアイベントのメトリクス layout トータ ル行数 検索キー ns/ op condMiss/ cyc/op inst/op cond/op op L1DmissLd/ op fromLLC/ fromL2/op op no-digest 10k 固定1件 12.4 53.5 464.1 57 0 0 0 0 digest+sim d 10k 固定1件 11.1 48 433.1 71 0 0 0 0 no-digest 10k 固定100件 23.1 100.1 529.4 67.3 0.02 8.72 6.37 0.01 digest+sim d 10k 固定100件 19.9 86 464.1 59.8 0.01 9.39 6.09 0.01 no-digest 10k ランダム 84.3 366.9 528.8 67.2 6.11 7.64 6.31 0.01 digest+sim d 10k ランダム 47.5 206.1 462 60.4 3.09 7.5 5.66 0.02

Slide 62

Slide 62 text

Digest導入前後のハードウェアイベント計測結果 命令数、分岐の数が L2ミス変化なしか悪化 削減された layout トータ ル行数 検索キー ns/ op condMiss/ cyc/op inst/op cond/op op L1DmissLd/ op fromLLC/ fromL2/op op no-digest 10k 固定1件 12.4 53.5 464.1 57 0 0 0 0 digest+sim d 10k 固定1件 11.1 48 433.1 71 0 0 0 0 no-digest 10k 固定100件 23.1 100.1 529.4 67.3 0.02 8.72 6.37 0.01 digest+sim d 10k 固定100件 19.9 86 464.1 59.8 0.01 9.39 6.09 0.01 no-digest 10k ランダム 84.3 366.9 528.8 67.2 6.11 7.64 6.31 0.01 digest+sim d 10k ランダム 47.5 206.1 462 60.4 3.09 7.5 5.66 0.02

Slide 63

Slide 63 text

Digest導入前後のハードウェアイベント計測結果 layout トータ ル行数 検索キー no-digest 1000k 固定1件 21.2 91.8 719.1 89 0 0 0 0 sorted+simd 1000k 固定1件 17.6 76.2 651.1 110 0 0 0 0 no-digest 1000k 固定100件 50.9 221 782.7 98.6 0.07 38.1 20.98 8.92 sorted+simd 1000k 固定100件 49.2 216.1 672.5 89.6 0.01 44.64 23.34 6.43 no-digest 1000k ランダム 212.3 924.8 784.6 98.7 9.3 28.68 7.94 16.17 sorted+simd 1000k ランダム 124.3 547.3 674.9 89.4 4.87 33.92 7.24 17.26 ns/ op cyc/ op condMiss/ inst/op cond/op op L1DmissLd/ op fromLLC/ fromL2/op op

Slide 64

Slide 64 text

Digest導入前後のハードウェアイベント計測結果 L1ミス悪化 行数の増加につれ fromLLC/ L2ミス微改善 fromL2/op op layout トータ ル行数 検索キー no-digest 1000k 固定1件 21.2 91.8 719.1 89 0 0 0 0 sorted+simd 1000k 固定1件 17.6 76.2 651.1 110 0 0 0 0 no-digest 1000k 固定100件 50.9 221 782.7 98.6 0.07 38.1 20.98 8.92 sorted+simd 1000k 固定100件 49.2 216.1 672.5 89.6 0.01 44.64 23.34 6.43 no-digest 1000k ランダム 212.3 924.8 784.6 98.7 9.3 28.68 7.94 16.17 sorted+simd 1000k ランダム 124.3 547.3 674.9 89.4 4.87 33.92 7.24 17.26 ns/ op cyc/ op condMiss/ inst/op cond/op op L1DmissLd/ op

Slide 65

Slide 65 text

Digestの問題点: バイナリサイズが膨れる 実行速度 改善の代わりに メモリフットプリント悪化 世の中そんな うまい話は ねーってか

Slide 66

Slide 66 text

互換性を破壊しまくりつつバイナリサイズ削減 Before After: キーの長さが64bit以下 (整数キーなど) / internalノードの場合

Slide 67

Slide 67 text

二分探索の改善を考える // lower bound検索 var min = 0; var max = count; while (min < max) // 終了判定: 範囲が尽きるまで(~log2 n 周) { var mid = min + ((max - min) >> 1); if (digests[mid] < targetDigest) // ★ 条件分岐 — ランダムキーでは 50/50 min = mid + 1; else max = mid; } return min;

Slide 68

Slide 68 text

二分探索の改善を考える var min = 0; var max = count; while (min < max) 次に何番めの要素を見に行くか予測困難。 メモリアクセスパターンが複雑 // 終了判定: 範囲が尽きるまで(~log2 n 周) { var mid = min + ((max - min) >> 1); if (digests[mid] < targetDigest) // ★ 条件分岐 — ランダムキーでは 50/50 min = mid + 1; else max = mid; } return min; CPUの分岐予測が機能しない

Slide 69

Slide 69 text

Cache-friendly二分探索(Eytzingerレイアウト)

Slide 70

Slide 70 text

Cache-friendly二分探索(Eytzingerレイアウト) // lower bound検索なんとこれだけ…! var i = 1; while (i <= n) // n=完全二分木の要素数 log₂(count+1) { i = 2 * i + (digests[i] < targetDigest ? 1 : 0); // 木を一段降りる } return i - n - 1;

Slide 71

Slide 71 text

Cache-friendly二分探索(Eytzingerレイアウト) // lower bound検索なんとこれだけ…! var i = 1; while (i <= n) // n=完全二分木の要素数 log₂(count+1) { i = 2 * i + (digests[i] < targetDigest ? 1 : 0); // 木を一段降りる } return i - n - 1; ldr x5, [x0, w5, UXTW #3] ; digests[i-1] をロード cmp x5, x2 ; targetDigest と比較 → 結果は NZCV フラグに立つ cset x5, lo ; ★ フラグが LO(符号なし<)なら x5=1、他は x5=0 add ; i = (i << 1) + x5 w3, w5, w3, LSL #1 — 2i+cond が 1 命令

Slide 72

Slide 72 text

Eytziger導入前後のハードウェアイベント計測結果 layout rows keys no-eytzinger 10k fixed 11.1 48 433.1 71 0 0 0 0 eytzinger 10k fixed 15.1 65.5 475.1 70 0 0 0 0 no-eytzinger 10k repeat1000 19.9 86 464.1 59.8 0.01 9.39 6.09 0.01 eytzinger 10k repeat1000 28.4 124.1 482.2 72 0.01 5.89 5.1 0.01 No-eytzinger 10k norepeat 47.5 206.1 462 60.4 3.09 7.5 5.66 0.01 eytzinger norepeat 28.5 124.5 482.2 72 0.01 6.02 5.21 0.01 10k ns/op cyc/op inst/op condMiss/ L1DmissLd fromLLC/ op /op fromL2/op op cond/op

Slide 73

Slide 73 text

Eytziger導入前後のハードウェアイベント計測結果 layout rows keys no-eytzinger 10k fixed 11.1 48 433.1 71 0 0 0 0 eytzinger 10k fixed 15.1 65.5 475.1 70 0 0 0 0 no-eytzinger 10k repeat1000 19.9 86 464.1 59.8 0.01 9.39 分岐予測ミス改善 6.09 0.01 eytzinger 10k repeat1000 28.4 124.1 482.2 72 0.01 5.89 5.1 0.01 No-eytzinger 10k norepeat 47.5 206.1 462 60.4 3.09 7.5 5.66 0.01 eytzinger norepeat 28.5 124.5 482.2 72 0.01 6.02 5.21 0.01 10k ns/op cyc/op inst/op condMiss/ L1DmissLd fromLLC/ op /op fromL2/op op cond/op

Slide 74

Slide 74 text

キャッシュ追い出しアルゴリズム メモリに読み込んでいるデータ

Slide 75

Slide 75 text

キャッシュ追い出しアルゴリズム メモリに読み込んでいるデータ

Slide 76

Slide 76 text

キャッシュ追い出しアルゴリズム メモリに読み込んでいるデータ あふれる どれを追い 出す??

Slide 77

Slide 77 text

単純なFIFO class FifoPageCache { readonly ConcurrentDictionary map = new(); // 本体 readonly ConcurrentQueue queue = new(); // 順番管理 readonly int capacity; int count; public Page GetOrLoad(PageNumber pageNumber) { if (map.TryGetValue(pageNumber, out var page)) { return page; // ★ ヒット時は何もしない } page = LoadFromDisk(pageNumber); if (map.TryAdd(pageNumber, page)) { queue.Enqueue(pageNumber); if (Interlocked.Increment(ref count) > capacity && queue.TryDequeue(out var victim)) // 最古エントリを { if (map.TryRemove(victim, out var evicted)) { } } } } } return page; Interlocked.Decrement(ref count); evicted.Dispose(); // 問答無用で追い出す

Slide 78

Slide 78 text

単純なFIFO • キャッシュヒット時は非常に速い • ただConcurrentDictionaryを引くだけ • ぜんぜん賢くない代わりに、クリティカルセクションは小さい • キャッシュミスが少ないのであればスケーラビリティが高い • キャッシュミスが少ないのであれば。

Slide 79

Slide 79 text

単純なFIFO • キャッシュ内には、ほとんど一回しか使われないものが珍しくない • 頻繁に使われる奴と一回しか使われない奴が同等に扱われてしまう

Slide 80

Slide 80 text

S3FIFO • 単純なFIFOを改良したアルゴリズム • 改良点 • Smallキュー、mainキュー、そしてghostテーブル、の3つの コレクション • 各キャッシュエントリにカウンタ(信頼性は適当) Simple Scalable Caching with Three Static FIFO queues (SOSP23) https://www.youtube.com/watch?v=5T2dHj0eHC0

Slide 81

Slide 81 text

S3FIFO Simple Scalable Caching with Three Static FIFO queues (SOSP23) https://www.youtube.com/watch?v=5T2dHj0eHC0

Slide 82

Slide 82 text

S3FIFO if (map.TryGetValue(pageNumber, out var e)) { // ★ ヒット時はこれだけ。CAS は 1 回だけ試して負けても気にしない // (freq はヒューリスティックなので厳密さ不要) var f = Volatile.Read(ref e.Freq); if (f < 3) Interlocked.CompareExchange(ref e.Freq, f + 1, f); return e.Page; } var entry = new Entry { Page = LoadFromDisk(pageNumber), Freq = 1 }; // 1 スタート if (!map.TryAdd(pageNumber, entry)) { /* 同時ロードは後勝ち省略 */ } if (ghost.TryRemove(pageNumber, out _)) { main.Enqueue(pageNumber); // ghost に痕跡あり = 一発屋ではなかった → M 直行 Interlocked.Increment(ref mainCount); } else { small.Enqueue(pageNumber); // 新顔はまず S へ Interlocked.Increment(ref smallCount); } EvictIfNeeded(); return entry.Page;

Slide 83

Slide 83 text

LRUアルゴリズムを改善していったが • けっきょく、キャッシュヒット時はDictionaryが最大のボトルネッ クになってきた • ほしいページのIDを指定して、現在キャッシュに存在するページ を取得したい。 • キャッシュの内容が変わるとDictionaryへの書き込みが発生する • ConcurrentDictionaryはreadはロックフリーだがwriteは部分 的にロックがある • Dictionaryである限り、メモリの参照局所性は高くない

Slide 84

Slide 84 text

ConcurrentDictionaryをどう速くする? • けっきょくDictionaryであることが大きな壁

Slide 85

Slide 85 text

ConcurrentDictionaryをどう速くする? • 理想的な解: Dictionaryをやめる • DB全体がreadonlyであることを活かす • → ページIDは増えないし減らない • → 全ページにあらかじめ0…N-1 の連番を振っておけば Dictionaryではなく、T[] で よくなる • これでキャッシュヒット時は最速。 • キャッシュミス時は、連番からページ相対アドレスを引く操作が必要になる • が、キャッシュミスについては、どうせ I/Oが発生するのでそこはボトル ネックではない

Slide 86

Slide 86 text

ページキャッシュが保持するフィールド: 改良後 // ページの実体はただの配列に readonly Entry?[] entries; // キューは、固定長かつ、multi-producer, single-consumerな軽量キューを自作 readonly MpscRingQueue sQueue; readonly MpscRingQueue mQueue; // ghost管理用 readonly int[] ghostEpoch; int ghostClock; // 連番から実際の相対アドレスを引く用のテーブル(キャッシュミス時は必要) readonly long[] pageOffsets;

Slide 87

Slide 87 text

結果: DryDB v1.1 の read性能 Point lookup Find one value by key, 10,000 rows — time per query (lower is better) DryDB 17 ns RocksDB SQLite (CsSqlite, prepared + immutable) SQLite (CsSqlite, default) BenchmarkDotNet · .NET 10 · Apple M-series · 10,000 rows (int64 key, 13-byte value) · 4 KB pages 383 ns 552 ns 4,287 ns

Slide 88

Slide 88 text

結果: DryDB v1.1 キー範囲検索 Range scan Read 100 consecutive rows by key range — time per query (lower is better) DryDB 0.4 µs SQLite (CsSqlite, prepared + immutable) SQLite (CsSqlite, default) RocksDB BenchmarkDotNet · .NET 10 · Apple M-series · 10,000 rows (int64 key, 13-byte value) · 4 KB pages 5.7 µs 10 µs 10.1 µs

Slide 89

Slide 89 text

結果: DryDB v1.1 単一キー検索 Count by key range Count 8,000 rows in a key range — time per query (lower is better) DryDB 1.0 µs SQLite (CsSqlite, prepared + immutable) SQLite (CsSqlite, default) RocksDB BenchmarkDotNet · .NET 10 · Apple M-series · 10,000 rows (int64 key, 13-byte value) · 4 KB pages 102 µs 110 µs 713 µs

Slide 90

Slide 90 text

まとめ • C#でも、いやC#だからこそ最適化をかなり詰められた • C#製アプリケーションに組み込むDBは C#で書かれていると便利!