を
利用して、.Net 上での巨大行列や膨大な計測データ等を計算対象にしています。 LightDB クラスはそのような巨大データを効率的に扱うための軽量・高速な簡易型データベースです。
例題で示すような数十Gバイト以上のファイルサイズでも高速動作します。
多くのデータベースシステム(DBMS)では検索を高速に実行するため、データ本体とは別に、別途インデックスを作成します。
一般にインデックスは B-Tree という特別な構造(平衡多分木)を持ったファイルになります(詳細は B-Tree 等で検索してください)。
LightDB は多くの DBMS が採用している B-Tree 構造を持ったインデックス操作を .Net クラスとして組み込んだものです。
DBMS のインデックスはキーフィールドの値と、そのキーを含むレコードへの位置情報を管理します。
LightDB ではキーフィールドとデータレコードを一体化して管理します(別途管理することもできます)。
プログラミング手順
LightDB の詳細は後述するとして、プログラミングは大体以下のような手順になります。
[新規ファイルにキーとデータ(レコード)を追加]
//
// LightDB インスタンス作成:
// キー部分は int[] で配列サイズ 1
// データ部分は double[] で配列サイズ 2
//
LightDB<int, double> db = new LightDB<int, double>(1, 2);
// データベースファイル "sample.ldb" を新規にオープン
db.OpenNew("sample.ldb");
// キーとデータをセットしてデータベースファイルに追加
int [] key = new int [1];
double [] data = new double[2];
while(....)
{
// データベースに追加するキーとデータのセット
key[0] = キー
data[0] = データ1
data[1] = データ2
// キーとデータをデータベースファイルに追加する。
db.AddRecord(key,data);
}
// ファイルのクローズ
db.Close();
[既存ファイルからキーを指定してデータを検索]
//
// LightDB インスタンスの作成は、前例と同じ。
//
LightDB<int, double> db = new LightDB<int, double>(1, 2);
// 既存データベースファイル "sample.ldb" をオープン
db.Open("sample.ldb");
// キーを指定して対応するデータを検索
int [] key = new int [1];
double [] data = new double[2];
while(....)
{
// データベースから検索するキーのセット
key[0] = キー
// キー(key)を指定して対応するデータ(data)をデータベースファイルから読み込む。
if(db.ReadRecord(key,data)==0)
{
// 読み込めた場合の処理
データ1 = data[0];
データ2 = data[1];
..............
}
}
// ファイルのクローズ
ldb.Close();
簡単例: BigMatrix と BigVector
プログラミング手順は前記した通りですが、ここでは、若干異なるインデクサを利用した
行列(BigMatrix)の具体例と実行結果を以下に示します。
※ LightDB はジェネリッククラスです。
// メモリー上に作成できないような巨大行列を扱う例
public class BigMatrix : LightDB<int, double>
{
public BigMatrix(string dbPath)
: base(2, 1)
{
Open(dbPath);
}
};
// BigVector は大抵の場合一次元配列で代用可能ですが、行列と同様に以下のように定義できます。
public class BigVector : LightDB<int, double>
{
public BigVector(string dbPath)
: base(1, 1)
{
Open(dbPath);
}
};
継承せずに、 LightDB A = new LightDB<int,double>(2,1);
のように直接インスタンスを作成してもかまいません。
<int,double>(2,1) はキーフィールドが int でデータフィールドが double の配列で、サイズはそれぞれ 2 と 1 を意味します。
つまり、2個のキーフィールド [i,j] で1個の double 値 A[i,j] を検索する、連想記憶配列を作成することになります。
連想記憶配列を作成すれば、後は通常の行列やベクトルとまったく同じように使用可能です。
違いは配列サイズの制限が無いこと、[i,j] で検索できなかった場合は値 0 が返ることなどです。
※ 例はインデクサ(A[i,j]とあたかも配列のようにアクセスする方法)によるレコードアクセスですが、他にメソッドを使用したさまざまな機能が提供されています。
※ データベースファイル名(と拡張子)は任意です。
public void Test()
{
int n = 50000;
BigMatrix A = new BigMatrix("A.ldb"); // 行列、A[i,j] 形式でアクセスする連想配列データベース。
double [] b = new double[n];
double [] c = new double[n];
// データのセット(書込み、値は計算時間に無関係なので適当…)
DateTime tm = DateTime.Now;
for(int i=0;i < n;++i)
{
b[i] = n - i;
for (int j = 0; j < n; ++j)
{
A[i, j] = i + j; // 配列要素値は "A.ldb" に保存されます。
}
}
Console.WriteLine("書き込み時間=" + (DateTime.Now-tm));
// 掛け算の計算(読み込み)
tm = DateTime.Now;
for (int i = 0; i < n; ++i)
{
double s = 0;;
for (int j = 0; j < n; ++j)
{
s += A[i, j] * b[j]; // A[i,j] 要素は "A.ldb" から読み込まれます。
}
c[i] = s;
}
Console.WriteLine("読み込み時間=" + (DateTime.Now - tm));
A.Verify(false);
A.Close();
}
実際の計算結果は以下のようになります(Windows 7、Intel CORE i7-2600S 2.8GHz 8GBメモリ)。
※勿論、HDDの性能やウィルスソフトの関係などで結果は異なってきます。
※特にウィルスソフトの場合、ウィルス検出機能をON/OFFにすることでI/O時間が大幅に異なることがあります。
書き込み時間=01:32:04.1101026
読み込み時間=01:17:06.7549277
LightDb データベースファイルの情報
LightDB version = 1
全ページ数 = 31250004
ページバイトサイズ = 1952
先頭削除ページ = 0
ファイルサイズ = 61000007808
キータイプ = 6[SINT]
キーバイトサイズ = 8
データタイプ = 10[DOUBLE]
データバイトサイズ = 8
ルートページ = 538089
最大アイテム数 = 80
内部メモリー(キャッシュ)の検査
LightDB ファイル構造の検査
有効レコード数 = 2500000000
有効ページ数 = 31250003
削除ページ数 = 0
B-Tree の高さ = 5
レコード占有率 = 99.999990%
データーベースファイル(A.ldb)の大きさは約 60GB という巨大なサイズで、計算時間が約1時間半をどう捉えるかは
利用者次第と思います。しかし「Intel CORE i7-2600S 2.8GHz 8GBメモリ」のパソコンでは
double で 50000 x 50000(要素数25億個) の2次元配列を扱うことはできません(32&64bitアプリケーション)。
1レコードの平均読み込み時間は ((60+17)*60+6)/(50000*50000) => 1.8504e-6
秒ということになります。
書込み時間と読み込み時間が同程度になっていますが、
例えばn = 10000;
程度で計測すると、書込み時間が約3分で読み込み時間は約2分弱となります。
上記テストは 32bit アプリケーションで実行しましたが、メモリー自体の負荷は少ないので 64bit アプリケーションで実行しても
結果に大きな違いは出てきません。
結果の「レコード占有率 = 99.999990%」とは、各ページが格納しているアイテム(レコード)がほぼ100%であることを
意味します(後述の「LightDB ファイルの構造」を参照してください)。つまり、データベースに隙間が殆どないということです。
また「B-Tree の高さ = 5」は、キーの検索に必要なディスクアクセスが最大でも 5 回で済むことを意味します。
[BigMatrix使用例: 連立方程式の解法]
あくまでも比較用としてですが、LU 分解による連立方程式の解法ソースコードを用意しました(例題に追加してください)。
基本的に搭載メモリーで扱える行列なら LuSolver クラス等(圧倒的高速性能を有しています)を使用すべきです。
BigMatrix を使用する唯一の選択肢は、搭載メモリーで扱えない、巨大行列のケースです。
しかし、そのような巨大行列を現実的な時間(と精度)内で解くことに BigMatrix が最適であるとは言い難いのも事実です。
データの検証と全体スキャン
上記例のデータを全て(昇順に)スキャン(Scan())して検証するのは以下のように簡単です。
public void Assert(bool f)
{
if (f) return;
StackFrame CallStack = new StackFrame(1, true);
string SourceFile = CallStack.GetFileName();
int SourceLine = CallStack.GetFileLineNumber();
MessageBox.Show("Assertion failed" + " File: " + SourceFile + " Line: " + SourceLine.ToString());
}
public bool EQ(double a, double b)
{
double eps = double.Epsilon * 2;
if (Math.Abs(a) <= eps && Math.Abs(b) <= eps) return true;
return (Math.Abs(a - b) / (Math.Abs(a) + Math.Abs(b))) <= eps;
}
public void Scan()
{
long n = 50000;
BigMatrix A = new BigMatrix("A.ldb");
DateTime tm = DateTime.Now;
// データの検証
int[] ij = new int[2];
double[] Aij = new double[1];
Assert(A.GetMinimumRecord(ij, Aij) == 0);
long nr = 0;
do
{
nr++;
Assert(EQ(Aij[0], (double)(ij[0] + ij[1])));
} while (A.GetNextRecord(ij, Aij) == 0);
Assert(nr == n * n);
Console.WriteLine("検証時間 = " + (DateTime.Now - tm));
A.Dispose();
}
結果は以下のようになります。
因みに、1レコードに対する平均アクセス時間は (40*60+31)/(50000*50000) => 9.72E-7
秒になります。
LightDB のレコード構成
LightDB が扱うレコードは先頭にキーフィールドがあり、次にデータフィールド(共に配列)が続く単純な構造です。
レコード例: LightDB A = new LightDB<int,double>(n,m);
LightDB ix = new LightDB<int,long>(2,2);
ix.Open("index.idx"); // インデックス "index.idx" のオープン
FileStream df = new FileStream("data.dat",FileMode.Truncate); // データファイル "data.dat" のオープン
int [] Key = new int [2];
long [] Data = new long[2]; // データレコードの位置情報
while(...)
{
… Key のセット …
… データレコードのセット …
Data[0] = df.Position; // データレコードのファイル上の位置
Data[1] = size; // データレコードのサイズ
df.Write(データレコードのバイト配列,0,size);
ix.AddRecord(Key,Data); // インデックスにキー(Key)とデータの位置情報(Data)を追加
}
LightDB を使用するには、上記のような2つのファイルを管理するより、キーとデータを(連結したレコードで)一括して一つのファイルで管理するのが一般的です。
(レコードサイズが大きい場合は DBMS を使用して、計算に必要な部分を DBMS より抽出してから LightDB ファイルとして管理することが考えられます。)
名前 | 説明 |
LightDB<K, D>(int ks, int ds) | LightDBコンストラクター。K と D はキーとデータのタイプ。 ks と ds はキーとデータの最大配列サイズ。 |
static void PrintInfo(string stDbPath) | LightDB ファイル情報をコンソール出力。 |
void OpenNew(string stDbPath) | 新規ファイルのオープン。ファイルが存在すればエラー。 |
void OpenRenew(string stDbPath) | 新規ファイルのオープン。既存ファイルは破壊されます。 |
void void Open(string stDbPath[, bool readOnly]) | ファイルのオープン。ファイルがなければ作成されます。readOnly=true なら書込み禁止になります。 |
void Flush() | キャッシュされていてディスクに書き戻す必要のあるページを全てディスクに書き込みます。 |
void Close() | オープンしたファイルのクローズ。 |
int AddRecord(K[] Key, D[] Data) | Key と Data のレコード追加。 |
int ReadRecord(K[] Key, D[] Data) | 指定した Key に対応するデータ(Data)の読み込み。 |
int SetupRecord(K[] Key, D[] Data) | 指定した Key が無ければレコード追加、あれば Data のみを更新。 |
int ChangeRecord(K[] Key, D[] Data) | 指定した Key に対応するデータの更新。 |
int DeleteRecord(K[] Key) | 指定した Key に対応するレコードの削除。 |
int GetMinimumRecord(K[] Key, D[] Data) | 最少キーと対応するデータの読み込み。 |
int GetMaximumRecord(K[] Key, D[] Data) | 最大キーと対応するデータの読み込み。 |
int GetNextRecord(K[] Key, D[] Data) | 前回処理されたキーの次に大きいキーとデータの読み込み。 |
int GetPrevRecord(K[] Key, D[] Data) | 前回処理されたキーの次に小さいキーとデータの読み込み。 |
int RereadRecord(K[] Key, D[] Data) | 前回処理されたキーとデータの読み込み。 |
int FieldIndex | インデクサ(やGetField()・SetField()メソッド)を利用してアクセスするデータ配列要素のインデックス(初期値は 0)。 |
D GetField(K[] Key) | FieldIndex で指定されるデータ配列(データ配列を Data とすると D = Data[FieldIndex] )要素の取得。 |
int SetField(K[] Key, D d) | FieldIndex で指定されるデータ配列要素の設定(データ配列を Data とすると Data[FieldIndex] = d )。 |
void Verify(bool detailed_output) | オープンされている LightDB ファイルの検証。結果はコンソールに出力。 |
int UserAreaSize | ヘッダ領域にユーザ固有の情報を保存できる領域のバイトサイズ。 |
byte[] UserArea | ヘッダ領域のユーザ固有情報。 |
D this[K k] | インデクサ。キー(の先頭要素)で検索したデータ配列の要素(Data[FieldIndex])。 |
D this[K k1,K k2] | インデクサ。キー(の先頭要素と2番目の要素)で検索したデータ配列の要素(Data[FieldIndex])。 |
各メソッドの戻り値(がある場合)はゼロなら正常終了。ゼロ以外は(同じ値のキーが存在して、レコード追加に失敗した場合などの致命的ではない)エラーを示します。
戻り値の値と意味は以下の通りです。
// 通常
ldb.AddRecord(new int []{1,2},new double [] {1.0,2.0}); // キー {1,2}、データ {1.0,2.0} が追加される。
// 配列が短い場合
ldb.AddRecord(new int []{3},new double [] {3.0}); // キー {3,0}、データ {3.0,0.0} が追加される。
// 配列が長い場合
ldb.AddRecord(new int []{4,5,6},new double [] {4.0,5.0,6.0}); // キー {4,5}、データ {4.0,5.0} が追加される。
上記例はエラーにならず、コメントで記述されている結果になります。
LightDB ldb = new LightDB<int,int>(1,3);
ldb.AddRecord(new int []{1},new int [] {1,2,3}); // キー {1}、データ {1,2,3} が追加される。
としたとき
// 通常
ldb.AddRecord(new string []{"AB"},new string [] {"あい"}); // キー "AB"、データ "あい" が追加される。
// 文字が短い場合
ldb.AddRecord(new string []{"A"},new string [] {"あ"}); // キー "A\0"、データ "あ\0" が追加される(読み込み時、'\0'は削除されます)。
// 文字が長い場合
ldb.AddRecord(new string []{"ABC"},new string [] {"あいう"); // キー "AB"、データ "あい" が追加される。
// 2以上の配列要素を指定した場合
ldb.AddRecord(new string []{"AB","CD"},new string [] {"あい","うえ"}); // キー "AB"、データ "あい" が追加される(先頭配列要素のみ処理対象)。
上記例はエラーにならず、コメントで記述されている結果になります。
long n = 1000000;
LightDB<long, string> ldb = new LightDB<long, string>(1, 40);
ldb.OpenRenew("Test.ldb");
DateTime dt = DateTime.Now;
for (long i = 1; i <= n; ++i)
{
ldb[i] = DateTime.Now.ToString() +"," + (double)i;
}
T.P("書込み時間 = " + (DateTime.Now - dt));
ldb.Close();
ldb.Open("Test.ldb");
dt = DateTime.Now;
DateTime tm;
for (long i = 1; i <= n; ++i)
{
string[] sts = ldb[i].Split(',');
Assert(DateTime.TryParse(sts[0],out tm));
Assert(i==(long)double.Parse(sts[1]));
}
T.P("読込み時間 = " + (DateTime.Now - dt));
ldb.Close();
結果は以下のようになります。
// あるキー([v0,v1,...])より大きいレコードの処理
key[0] = v0;
key[1] = v1;
...........
ldb.ReadRecord(key, data);
do
{
.... 処理 .....
} while (ldb.GetNextRecord(key, data) == 0);
// あるキー([v0,v1,...])より小さいレコードの処理
key[0] = v0;
key[1] = v1;
...........
ldb.ReadRecord(key, data);
do
{
.... 処理 .....
} while (ldb.GetPrevRecord(key, data) == 0);
※ReadRecord() や ChangeRecord() が失敗した場合(検索できなかった場合)、レコードポインターは
指定されたキーにいちばん近いレコード位置にセットされています。従って、GetNextRecord() や GetPrevRecord() で(検索できなかったキーの)
次や前のレコードを得ることは可能です。