a22_acl2_02
の編集
https://essen.osask.jp/?a22_acl2_02
[
トップ
] [
編集
|
差分
|
バックアップ
|
添付
|
リロード
] [
新規
|
一覧
|
単語検索
|
最終更新
|
ヘルプ
]
-- 雛形とするページ --
BracketName
EssenRev4
FormattingRules
FrontPage
Help
InterWiki
InterWikiName
InterWikiSandBox
K
MenuBar
PHP
PukiWiki
PukiWiki/1.4
PukiWiki/1.4/Manual
PukiWiki/1.4/Manual/Plugin
PukiWiki/1.4/Manual/Plugin/A-D
PukiWiki/1.4/Manual/Plugin/E-G
PukiWiki/1.4/Manual/Plugin/H-K
PukiWiki/1.4/Manual/Plugin/L-N
PukiWiki/1.4/Manual/Plugin/O-R
PukiWiki/1.4/Manual/Plugin/S-U
PukiWiki/1.4/Manual/Plugin/V-Z
RecentDeleted
SDL2_01
SandBox
WikiEngines
WikiName
WikiWikiWeb
YukiWiki
a21
a21_acl01
a21_bbs01
a21_challengers
a21_count
a21_edu01
a21_edu02
a21_edu03
a21_edu04
a21_edu05
a21_edu06
a21_edu07
a21_edu08
a21_edu09
a21_edu10
a21_edu11
a21_hlx000
a21_hlx001
a21_hlx001_1
a21_hlx001_2
a21_hlx001_3
a21_hlx002
a21_hlx002_1
a21_hlx003
a21_hlx003_1
a21_hlx004_1
a21_memo01
a21_opt
a21_opt02
a21_opt03
a21_p01
a21_special
a21_tl9a
a21_todo
a21_txt01
a21_txt01_10
a21_txt01_1a
a21_txt01_2
a21_txt01_2a
a21_txt01_2b
a21_txt01_3
a21_txt01_4
a21_txt01_5
a21_txt01_6
a21_txt01_6a
a21_txt01_7
a21_txt01_8
a21_txt01_8a
a21_txt01_9
a21_txt01_9a
a21_txt02
a21_txt02_10
a21_txt02_10a
a21_txt02_10b
a21_txt02_11
a21_txt02_11a
a21_txt02_12
a21_txt02_12a
a21_txt02_12b
a21_txt02_1a
a21_txt02_1b
a21_txt02_2
a21_txt02_2a
a21_txt02_3
a21_txt02_3a
a21_txt02_4
a21_txt02_4a
a21_txt02_5
a21_txt02_5a
a21_txt02_6
a21_txt02_6a
a21_txt02_6b
a21_txt02_6b_rev0
a21_txt02_6x
a21_txt02_7
a21_txt02_7a
a21_txt02_8
a21_txt02_8a
a21_txt02_9
a21_txt02_9a
a22_acl2_01
a22_acl2_02
a22_edu12
a22_intro01
a22_intro02
a22_intro03
a22_memman01
a22_memman02
a22_memman03
a22_memman04
a22_memman05
a22_memman06
a22_memman07
a22_memo01
a22_mingw_debug
a22_txt03
a22_txt03_1a
a22_txt03_1b
a22_txt03_2
a22_txt03_2a
a22_ufcs01
a23_bbs
a23_ec001
a23_ec002
a23_intro00
a23_intro000
a23_intro01
a23_intro02
a23_intro03
a23_intro04
a23_intro05
a23_intro06
a23_intro07
a23_intro08
a23_intro09
a23_intro10
a23_intro10wk1
a23_intro10wk2
a23_intro10wk3
a23_intro11
a23_intro12
a23_intro13
a23_intro13wk1
a23_intro14
a23_intro15
a23_intro16
a23_intro17
a23_intro17wk1
a23_intro18
a23_intro19
a23_intro90
a23_intro91
a23_neopixel1
a23_os01
a23_useSelfMade
a23_usm001
a23_usm002
a23_usm003
a23_usm004
a23_usm005
a23_usm006
a23_usm007
a23_usm008
a23_usm009
a24_AMap11
a24_AMapSim11
a24_AMemFile
a24_AMemMan
a24_aErrExit
a24_aFnv
a24_aOsFunc
a24_aQSort
a24_aXorShift32
a24_acl1T_doc01
a24_acl1Tiny
a24_acpp0
a24_buntan01
a24_cMin
a24_getTyp
a24_goodvalues
a24_idea001
a24_longdef
a24_memo01
a24_memo02
a24_osc20240310
a24_osc20241026
a24_picoLcd13
a24_picoTrain1
a24_programs
a24_raspberrypi01
a24_raspberrypi02
a24_schedule
a24_spc2tab
a24_tab2spc
a24_useSelfMade
a25_acl3
a25_buntan02
a25_buntan03
a25_buntan04
a25_buntan05
a25_kcas01
a25_kharc01
a25_kharc02
a25_kharc03
a25_kharc04
a25_kharc05
a25_kharc06
a25_kharcs1
a25_kharcs2
a25_kharcs3
a25_kharcs4
a25_kharcs5
a25_kharcs6
a25_kharcs7
a25_kharcs8
a25_kharcs9
aclib00
aclib01
aclib02
aclib03
aclib04
aclib05
aclib06
aclib07
aclib08
aclib09
aclib10
aclib11
aclib12
aclib13
aclib14
aclib15
aclib16
aclib17
aclib18
aclib19
aclib20
aclib21
aclib22
aclib23
aclib24
aclib25
aclib_bbs
arm64_01
avm0001
edu0001
edu0002
edu0003
esb02b_hrb
esb_dbg
esbasic0001
esbasic0002
esbasic0003
esbasic0004
esbasic0005
esbasic0006
esbasic0007
esbasic0008
esbasic0009
esbasic0010
esbasic0011
esbasic0012
esbasic0013
esbasic0014
esbasic0015
esbasic0016
esbasic0017
esbasic02a
esc0001
escm0001
essen_hist
esvm0001
esvm0002
esvm0003
esvm0004
esvm0005
esvm0006
esvm_i0
hh4a
idea0001
idea0002
idea0003
impressions
jck_0000
jck_0001
kawai
kbcl0_0000
kbcl0_0001
kbcl0_0002
kbcl0_0003
kbcl0_0004
kbcl0_0005
kbcl0_0006
kbcl0_0007
kclib1_0000
kclib1_0001
kclib1_0002
kclib1_0003
kclib1_0004
kclib1_0005
kclib1_0006
kclib1_0007
kclib1_0008
kclib1_0009
kclib1_0010
kpap0001
members
memo0001
osask4g
osask4g_r2
p20200311a
p20200610a
p20200610b
p20200624a
p20200711a
p20200716a
p20250813a
p20250813b
p20250813c
p20250815a
p20250903a
p20251006a
page0001
page0002
page0003
page0004
page0005
page0006
page0007
page0008
page0009
page0010
page0011
page0012
page0013
page0014
page0015
page0016
page0017
page0018
page0019
page0020
page0021
page0022
page0023
populars
seccamp
seccamp2019
sechack
sechack2019
seclang01
sh3_2020
sh3_2020_kw
sh3_2020_nk
sh3_2021_kw
sh3_2021_nk
sh3_2022_kw
sh3_2023_kw
sh3_2024_kw
sh3_2025_kw
sh3_kw_hist
termux001
termux002
text0001
text0001a
text0002
text0002a
text0003
text0004
text0005
text0006
text0006a
text0007
text0008
text0010
text0011
text0012
text0013
text0014
text0015
text0016
text0017
text0018
text0019
text0020
text0021
tl1c
tl2c
tl3c
tl3d
* acl2ライブラリ #2 -(by [[K]], 2022.11.24) ** (1) crit-bit木について #1 -''[1-1]'' -acl2ライブラリには、ACrit32L0B1などのクラスがあって、これはcrit-bit木を利用するためのクラスです。それでまずはcrit-bit木について説明します。 -''[1-2]'' -何か適当なオブジェクトの集合があって、それらを高速に検索したいとします。ソートした結果をポインタの配列で持っておくことができたら、あとは二分探索で探すだけでよくなるので、とても便利だと思います。これならO(logN)です(もしソートしてなかったら二分探索が使えないので、O(N)になってしまいます)。 -ソートといえばqsort()でしょう。ということで、ひとまず解決です。 -・・・でも待ってください。もし今100万件のデータがソート済みで、そこから一つだけデータを消すとしたらどうでしょうか?まず消すべきデータがどこにあるのかは二分探索ですぐにわかります。そして、そこを一番最後の要素で上書きすれば、これで一つ消したことになります。しかしこれだとソートされた状態が維持できていません。仕方ないのでqsortを呼びます。・・・これはいけません。O(NlogN)の時間がかかります。 -それならmemmove()で消したい場所以降を1つ分だけ前にずらすのはどうでしょうか。これならO(N)で済みます。qsort()よりもいいです。・・・同じ問題は、データを1件だけ追加するときにも生じます。二分探索で挿入すべき場所を探して、memmove()でそこに1つ分の空きを作って入れるやり方のほうがqsort()よりも速いです。・・・結局qsort()は最初にたくさんのデータをソートするには最適なアルゴリズムですが、その後1件ずつ更新するには不向きなアルゴリズムです。 -''[1-3]'' -しかしO(N)というのはお世辞にも速いアルゴリズムだとは言えません。・・・これより速いアルゴリズムがあります。それば二分木やB木などです。これらはソートされた結果をポインタの配列で保持するのをやめて、「木」と呼ばれるデータ構造で管理します。この状態でもデータの検索はO(logN)で行えます。またデータの削除や追加はそれぞれどちらもO(logN)で、先のO(N)よりも格段に速いです。ただし、単純な配列よりもメモリは使います。たいてい単純な配列バージョンの数倍の容量を必要とします。ですから開発者はスピード重視かそれとも省メモリ重視なのかで使い分けることになります。 -''[1-4]'' -とはいえ、人類のアルゴリズムの探求は終わりません。もっと速くできないでしょうか。・・・結局、二分木やB木の処理時間のほとんどは、キーデータの比較です。そう、単純な配列であっても二分探索の際にはO(logN)回のキーの比較をやっていました。だからこの比較処理をやめることができたら、ずっと速くできそうです。・・・そんな夢みたいな方法があるのか・・・あるんです。 -たとえば、キーのデータが、必ず大文字のアルファベット4文字で構成されているとしましょう。これは26^4=45.7万通りしかありません。それなら45.7万の要素を持つポインタの配列を作ればいいじゃないですか。これで、キーの値が"KAWA"のオブジェクトがどこにあるかをO(1)で検索できるようになります。データの1件ずつの追加も削除もすべてO(1)です。これはもはやハッシュ関数を使わないハッシュテーブルです。 -しかしまあ、実際はそんな「キーの値は45.7万通りしかない」みたいなことはまれで、たいていはテーブル配列をメモリ上に構築できません(大きすぎて無理)。でも要点は一つ見つかりました。「ほかのキーとの比較をやめればいい」これです。 -たとえばこうです。"KAWAI"と"OSASK"と"ESSEN"の3つのキーがあるのなら、1文字目だけ見ればそれだけでどこに行けばいいのかわかります。もしここで"OMAKE"も入って合計4つのキーになった場合は、1文字目が"O"の場合に限って2文字目を見て、"S"か"M"かで分岐します。こういう「木」を作ればいいのです。・・・この木をたどる際には、自分のキーだけ見ていればいいのです。他のキーとの比較は発生しません。・・・こういう木を「トライ木」と総称します。 -''[1-5]'' -先の例ではキーの文字に注目しました。文字だと分岐が多くなります。仮に1文字が1バイトだとしても256通りもあります。そんな木は管理が大変なので、キーをビット列に展開し、1ビットずつで見ていくことにして、3ビット目が"0"ならこっち、"1"ならあっち、みたいな木を作ります。これがcrit-bit木です。crit-bitというのは「クリティカル・ビット」の略らしいです。これは私の推測ですが、臨界点(クリティカル・ポイント)という言葉があって、水と氷のさかい目の温度とか、水蒸気と水のさかい目の温度などのことを指しているのですが(というか物質は水に限らないし、温度じゃなくて圧力の場合も臨界点なのですが)、そういう、AかBかのぎりぎりのさかい目ということで、クリティカル・ビットなのかなーと。1ビットで枝分かれするのであれば、分岐はそれぞれ2つしかありません。二分木とにたような木になって理解しやすいです。木をたどるときに他のキーとの比較はないので、crit-bitはトライ木のうちの一つです。 -''[1-6]'' -crit-bit木に関するリンク集です。 --https://dankogai.livedoor.blog/archives/51853853.html --https://cr.yp.to/critbit.html --https://qiita.com/Pseudonym/items/c2ef9a3a574b9e047287 ** (2) crit-bit木について #2 -''[2-1]'' -ここで、ベンチマーク結果を示したいと思います。・・・0から999999まで、数を数えて文字列にします。これで"0", "1", "2", "3", ... という100万件の文字列データができます。これをqsortでソートします。その際に比較関数を int qscmp(const void *a, const void *b) { return strcmp(*(char **) a, *(char **) b); } -としてみました。すると当方の環境(Kano PC, Celeron N4000)では、初期の並びを配列に書き込んでqsort()が終わるまでに340ミリ秒かかりました。ソート結果を出力するとこうなっていました。 0 1 10 100 1000 10000 100000 100001 100002 100003 100004 100005 100006 100007 100008 100009 10001 100010 100011 100012 -いい感じに混ざり合っているのがよくわかります。次に、比較関数を改良しました。strcmp()は遅いので、使わない形に書きました。 int qscmp(const void *a, const void *b) { const unsigned char *ca = *(const unsigned char **) a, *cb = *(const unsigned char **) b; if (ca[0] != cb[0]) return ca[0] - cb[0]; if (ca[1] != cb[1]) return ca[1] - cb[1]; if (ca[2] != cb[2]) return ca[2] - cb[2]; if (ca[3] != cb[3]) return ca[3] - cb[3]; if (ca[4] != cb[4]) return ca[4] - cb[4]; if (ca[5] != cb[5]) return ca[5] - cb[5]; return ca[6] - cb[6]; } -こうすると、286ミリ秒になりました。もちろん結果は同じです。ではもっと改良します。32bitのintを使って、4文字ずつ比較します。 int qscmp(const void *a, const void *b) { const unsigned int *ca = *(const unsigned int **) a, *cb = *(const unsigned int **) b; if (ca[0] != cb[0]) return ca[0] - cb[0]; return ca[1] - cb[1]; } -この比較を成功させるためには、文字列を加工する必要があります。x86はリトルエンディアンなので、char *s;のs[0]が上位に来てくれないのです。それでエンディアンを入れ替える処理を追加しました。するとソートに要する時間は278ミリ秒になりました(このキーの加工にかかる時間はこの278ミリ秒には含まれません)。32ビット環境ではこれくらいがqsort()での限界かなと思います。 -''[2-2]'' -では同じことをACrit32L0B1でやってみました。100万件の文字列を作って、やはりキーを都合のいい形式に加工しておきます(ビットを入れ替える)。また空っぽの木も準備しておきます。ここから処理時間を計測開始です。ACrit32L0B1_ins()でデータを1つずつ追加していきます。合計100万件です。そしてその後、qsort()と結果が同じ形式になるように配列にポインタを出力させます。この時間を計りました。100万回のinsに186ミリ秒かかって、配列への出力に20ミリ秒かかりました。合計で206秒です。qsort()での最速の278ミリ秒よりも明らかに速いです! -''[2-3]'' -話はこれで終わりではありません。実はACrit32L0B2とACrit32L0B4というクラスもあります。使い方は同じなのですが、速さが違います。この違いは何かというと、~B1は1ビットずつのcrit-bit木なのですが、~B2は2ビットずつのCrit-bit木なのです。~B4は4ビットずつのcrit-bit木です。結果を以下にまとめておきます。 |qsrot #1|RIGHT:340ms| |qsort #2|RIGHT:286ms| |qsort #3|RIGHT:278ms| |ACrit32L0B1|RIGHT:186+20=206ms| |ACrit32L0B2|RIGHT:145+17=162ms| |ACrit32L0B4|RIGHT:127+15=142ms| -このようにこの例ではqsort()よりも高速になりましたが、他のキーに対してはまた別の結果になるだろうと思います。またC++のSTLのsortはqsort()よりも3~4倍くらい速いという話を見聞きしたことがあるので、ACrit32はそれには負けていると思います。やっぱり高速に1件ずつ追加したり削除したりできることを優先していると、まとめて一気にソートするアルゴリズムには勝てないです、たぶん。でもその差はもうあまり大きくはないようです。 -''[2-4]'' -上記の例では文字列は最長でも6バイトでした。もっと長い文字列でやったら当然遅くなると思いますが、さてどの程度遅くなるでしょうか。実験してみました。文字列はUTF8で全角文字にしました。これで1文字当たり3バイトになるので、最長18バイトになります。 -qsortの比較関数はこうしました。 int qscmp(const void *a, const void *b) { const unsigned int *ca = *(const unsigned int **) a, *cb = *(const unsigned int **) b; if (ca[0] != cb[0]) return ca[0] - cb[0]; if (ca[1] != cb[1]) return ca[1] - cb[1]; if (ca[2] != cb[2]) return ca[2] - cb[2]; if (ca[3] != cb[3]) return ca[3] - cb[3]; if (ca[4] != cb[4]) return ca[4] - cb[4]; return ca[5] - cb[5]; } -結果は以下の通りです。 |qsort|RIGHT:328ms| |ACrit32L0B1|RIGHT:203+21=224ms| |ACrit32L0B2|RIGHT:175+18=193ms| |ACrit32L0B4|RIGHT:160+16=176ms| //-qsortでは328/278=1.18倍になりましたが、ACrit32L0B4では176/142=1.24倍になりました。 -''[2-5]'' -ではキーをもっと短くしたらどうでしょうか。今回は最長でも数字が6ケタなので、これをBCD(2進化10進数)という方法で表現すれば32bitに収まりそうです。BCDは簡単に言うと、数字1ケタを4ビット(=0~15)で表現する方法です。だだし今回は必要な文字として0~9のほかにスペースもあるので、スペースを0として、0~9を1~10に対応させて変換しています。 -qsortの比較関数はこうしました。 int qscmp(const void *a, const void *b) { const unsigned int *ca = *(const unsigned int **) a, *cb = *(const unsigned int **) b; return ca[0] - cb[0]; } -一方でACrit32のほうも、キーの長さが32bitの固定長なら、より高速なモードがあります。それでいろいろ試しています。結果は次の通りです。 |qsort|RIGHT:179ms|| |ACrit32L0B1|RIGHT:176+22=198ms|可変長モード| |ACrit32L1B1|RIGHT:153+22=175ms|固定長32bitキー専用クラス| |ACrit32L0B2|RIGHT:137+15=152ms|可変長モード| |ACrit32L1B2|RIGHT:120+15=135ms|固定長32bitキー専用クラス| |ACrit32L0B4|RIGHT:112+11=123ms|可変長モード| |ACrit32L1B4|RIGHT:102+11=113ms|固定長32bitキー専用クラス| -ちなみにACrit32L0Bxには固定長モードもありますが、結果は可変長モードとほぼ同じでした。 ** (3) crit-bit木について #3 -''[3-1]'' -acl2ライブラリが提供するACrit32LxBxクラスは以下の機能を持ちます。 |init|初期化| |deinit|後始末| |ins|データの追加| |srch0|検索(キーを指定):データを返す| |srch1|検索(ソート順でn番目):データを返す| |srch2|検索(キーを指定):nを返す(註1)| |srch3|検索(キーを指定):nを返す(註1)| |srch4|srch0の連続指定(註2)| |srch5|srch1の連続指定(註2)| |del0|削除(キーを指定):データを返す| |del1|削除(ソート順でn番目):データを返す| |annl|キーの加工(アニーリング)(註3)| --[註1] srch2とsrch3の違いを説明します。srch2が木の中にあるデータをキーで指定して、それが何番目かを問い合わせるためのものです。だからキーが見つからない場合、-1を返します。srch3はキーが木の中にあればsrch2と同じ挙動ですが、もしない場合は、「仮にそのキーを今木にinsしたら何番目に入るか?」を考えてそのnを返します。・・・srch3の使い道ですが、たとえば、key=1000とkey=2000でそれぞれのnを出して、1000~2000の間にいくつのデータがあるかを数えたりすることができます。 --[註2] 指定されたキーから連続で複数のデータポインタを得られます。連続というのは、ソート順での連続です。srch0やsrch1を何度も呼ぶよりもはるかに高速です。 --[註3] キーを検索しやすい形に加工します。主にビットを並べ替えます。これをひとまずアニーリング(焼き入れ、焼きなまし)と名付けましたが、これは一般的な用語ではなく、私が無理やり考えた呼び名です。ins/srch/delでキーを指定するときは、必ずannl済みのもので指定する必要があります。 -''[3-2]'' -世間で一般的に知られているcrit-bit木は、srch1~5, del1のようなことができません。それはそれぞれのデータがソート順で何番目なのかをO(logN)で計算するための情報を持っていないせいです。でもACrit32はそれらの情報を持っているので、これらの問い合わせに応えることができます。その代わり、各ノードのサイズは 3 * sizeof (int) ではなく、 5 * sizeof (int) になっています(ACrin32LxB1の場合)。 ||B1|B2|B4| |pos:ビット位置|1|1|RIGHT:1| |ptr:ポインタ|2|4|16| |num:そのポインタの先にいくつデータがあるか|2|4|16| --numは実はアルゴリズムを工夫すれば、1,3,15にできることが分かっているのですが、処理を単純化して高速にするために、ポインタと同じ数だけ持たせています。 --num==1の時、対応するポインタはデータを指しています。num>=2のとき、対応するポインタはノードを指しています。 ** (4) crit-bit木について #4 -''[4-1]'' -これだけいろいろできれば、例えばこんなことができます。 -malloc/free処理を考えます。・・・mallocするときに、要求サイズにできるだけ近い空きブロックを高速に見つけたいです。それはACrit32を使えば簡単にできそうです。 -それでは性能を比べてみたいと思います。まずこんなプログラムを作りました。 #include <stdio.h> #include <stdlib.h> #include <time.h> int main() { int i, sz = 16 * 1024, n = 10; void **p = malloc(1024 * 1024 * sizeof (void *)), *q; free(malloc(1026 * 1024 * 1024)); // 1GBくらいを確保して解放(意図しない断片化を防ぐため). for (i = 0; i < n; i++) p[i] = malloc(sz); q = malloc(sz * 2); for (i = 1; i < n; i += 2) free(p[i]); clock_t t0 = clock(); for (i = 0; i < 1000000; i++) { free(q); free(p[0]); p[0] = malloc(sz); q = malloc(sz * 2); } clock_t t1 = clock(); printf("%f[sec]\n", (double) (t1 - t0) / CLOCKS_PER_SEC); return 0; } -最初に16KBのメモリ領域をn個確保します。次に32KBのメモリ領域を1個確保します。そしてn/2個の16KBのメモリ領域を解放します。このときにメモリの空きがとびとびになるようにします。これで準備完了です。 -ここで free(q); free(p[0]); p[0] = malloc(16KB); q = malloc(32KB); を実行します。これは解放してから確保し直すという、かなり無意味なことをやっています。でもこの処理をK&R型のmalloc()はかなり苦手としています。一番時間がかかるのは q = malloc(32KB); のところです。n/2個の16KBしかない空き領域を読み飛ばして、やっと32KB以上の空きメモリを見つけることができるのです。ここでは n=10 なので大したことはないのですが、nが大きくなると結構な負担になります。 -ただし、それでも1回やったくらいでは大して時間はかからないので、100万回繰り返して、かかった時間を計ります。 -次にACrit32版のほうについてです。以下のようなクラスを作り、initして、本家のmalloc()で1GBくらいを取ってきて、MemManの管理下に組み入れます。あとはすべてMemManに対するmalloc/freeに書き換えるだけです(条件を同じにしないと比較にならないので)。 AClass(MemMan) { ACrit32L0B1 idxSiz; // 空きメモリブロック情報オブジェクトの、sizメンバをキーとするインデックス. }; -mallocではidxSizを使って最適な空きメモリブロックをO(logN)で見つけてきます。見つけて必要なメモリを切り取った後に余りが出れば、それはidxSizに登録します。・・・freeでは、前後の領域とマージできるときはマージして、それからidxSizに登録します。 ||n=10|n=65536| |オリジナル(msvcrt.dllのmalloc/freeを使用)|RIGHT:599ms|RIGHT:4178ms| |ACrit32L0B1を使ったmalloc/free|RIGHT:279ms|RIGHT:533ms| |ACrit32L0B2を使ったmalloc/free|RIGHT:293ms|RIGHT:427ms| |ACrit32L0B4を使ったmalloc/free|RIGHT:399ms|RIGHT:493ms| -これを見ると、「ACrit32L0B1を使ったmalloc/free」がシンプルで一番よさそうに思います。n=10の時点で、すでに標準のmalloc/freeの2倍の速度が出ています。これで、ACrit32L0B1の基本的なオーバヘッド(Nによらないオーバーヘッド)は十分に小さいことが分かります。n=65536では性能の差は圧倒的になっています。 -高性能なmallocとしてはdlmallocが知られていますが、このMemMan-mallocはサイズごとに処理を分けるようなこともなく、ごく単純にK&R型のmallocを速くしたような感じになっています。 -なお、言うまでもないですが、メモリ消費量の観点では、crit-bit木の分も必要になるため、K&R型と比べれば劣っています。 ** (5) crit-bit木について #5 -''[5-1]'' -ACrit32L0B1などでは、データオブジェクトの中にアニール済みのキーデータを保持する必要があります。しかしcrit-bit木に登録するために、符号付き整数の符号ビットを反転させたり、浮動小数点形式も加工することになるので、アニールを解いてそれから元に戻すことも容易ではありますが、不便です。そうなるとキーのためのデータと元のデータの両方を保持することになり、メモリ消費量の観点からは好ましくありません。 -そのためオプションとして、若干の速度低下はあるものの、crit-bit木のためのキーデータを一切保持しないで済むモードを付けました。このモードでは必要に応じて無加工のデータからcrit-bit木のためのキーデータを生成します(そういう関数を用意してください、クラスから必要に応じて呼び出します)。 -そもそもcrit-bit木では、キーデータが必要になるのは、データ追加1回につき、2回だけです(追加するデータのキーで1回、登録済みデータのうち、crit-bit木のアルゴリズムで選ばれたデータのキーで1回)。検索1回についてもその程度ですし、削除1回についでもその程度です。ですからqsortの比較関数と比べると、呼ばれる頻度はずっと少ないです。 -''[5-2]'' -結局、crit-bit木の使いどころはどこなのかと考えました。 --[1]ハッシュテーブルの代わりに使う: ハッシュテーブルはうまくいっているときは確かにO(1)(というか厳密にはO(m)ですが)なんですが、衝突が起きてしまうと性能は急激に悪くなってしまいます。つまりワーストケースの性能はかなり悪いわけです。一方でcrit-bit木はベストでもワーストでもO(m)なので、とても安定しています。ただまあポインタをたどる処理は処理的には少し重いので、衝突がほとんどないときのハッシュテーブルの速度は出ないだろうとは思います。でもcrit-bit木にすれば、ハッシュ関数をどうしたらいいかとか、テーブルサイズを動的に拡大縮小するかどうかなどを悩む必要は一切ありません。ハッシュテーブルのオープンアドレス法では、データを消すのは削除マークを入れるしかないですが(ハッシュテーブルを作り直すこともできますが)、crit-bit木ではちゃんと消せて性能が上がります。 --[2]平衡木の代わりに使う: 性能を比較してないので断言できないのですが、よほどキーの長さが長くない限りは、crit-bit木が性能で大きく負けることはなさそうな気がします。メモリ消費量の点からも他の平衡木に比べて何ら見劣りすることはありません(ACrit32は、srch1やsrch3を可能にするために冗長になっていますが)。ディスク上の木構造に有利と言われているB木に勝てるかどうかはわかりません。でもなんか工夫したら、いけそうな感じはします。 --[3]全文検索に使う: 数万字、数百万字、なんならそれ以上のテキストがあったとして、それを先頭から1文字ずつ削りながら、すべてcrit-bit木に登録したとします。ここで探索したい文字列で検索すれば、O(logN)くらいで見つけられると思います。この用途のためにはsuffix-arrayやLCP-arrayが有名ですし、そのほうが消費メモリは少なくて済みますが、しかしこれらは後から検索対象テキストを少量ずつ追加するのには向いていません。一方で、crit-bit木や平衡木なら、少量ずつの追加に十分に対応できます。 -''[5-3]'' -ハッシュテーブルとcrit-bit木での性能比較をやってみました。 -テストに使ったのは、適当なHLX向けのサンプルプログラムをつないで一つのテキストファイルし、それをlexerでトークンに分けて、さらにトークンが1文字以下のものは捨てて2文字以上のものだけにして、トークン文字列をトークン番号に変換する処理を、10万回繰り返す処理です。1文字のトークンを避けたのは、「1文字の場合はサイズが256の配列を作ってそれで一発で求める」という処理を書くと思うので、それ以外の長さのトークンをいかに速く処理できるかを見たかったためです。なお、文字列リテラルには極端に長いものもあって処理が面倒になってきたので、それはすべて"str"と書いてあるとみなして処理しています。結果的にトークンはすべて2~16文字になりました。 -ACrit32L0Bxでは、すべてのキーは16バイトの固定長であるとしました。そうするほうが簡単だったので。ハッシュテーブルのほうは、ハッシュ関数にFNVの32ビット版を使っています。Hash 582 はハッシュテーブルの大きさが582ということです。582は1bitのcrit-bit木を限界まで削ったときと同じ消費メモリの大きさのハッシュテーブルです。 |ACrit32L0B1|RIGHT:1425ms|| |ACrit32L0B2|RIGHT:999ms|| |ACrit32L0B4|RIGHT:746ms|ACrit32ではこれが最速| |hash 582|RIGHT:879ms|| |hash 512|RIGHT:839ms|| |hash 1024|RIGHT:812ms|ハッシュ関数をカスタマイズしないのならここが限界| -うーんハッシュテーブルのパワーとはこんなものだったでしょうか?そんなことはないはず!と思って改良しました。ハッシュ関数の計算を16バイト固定にはしないで、長さが短いときは適当に打ち切ることにしました。これでも今回の用途に限れば問題ないはずです。 |ACrit32L0B1|RIGHT:1425ms|| |ACrit32L0B2|RIGHT:999ms|| |ACrit32L0B4|RIGHT:746ms|ACrit32ではこれが最速| |hash 582|RIGHT:879ms|| |hash 512|RIGHT:839ms|| |hash 1024|RIGHT:812ms|ハッシュ関数をカスタマイズしないのならここが限界| |hash2 582|RIGHT:493ms|| |hash2 512|RIGHT:453ms|| |hash2 1024|RIGHT:413ms|ハッシュ関数をカスタマイズすればここまでいける| -ハッシュテーブルに関しては、テーブルの大きさを4096や65536なども試しましたが、そもそも1024の段階でほとんど衝突していないようで、性能向上は見られませんでした。むしろキャッシュヒット率が下がってきて、性能低下するくらいです。 ** (6) crit-bit木について #6 -[考察]Crit32L0B1とCrit32L0B4で何が違うか? --[1]最大で4倍速く枝をたどれる(2つしか入っていないときは1倍) --[2]16分岐すべてが埋まっていれば・・・B1だと5*sizeof(int)*15, B4だと33*sizeof(int)。だから0.44倍くらい省メモリになる。 -[考察]ハッシュテーブルに対する優位性は? --たとえば、Alfa, Bravo, Charlie, Delta, Echoみたいなキーだった場合、crit-bitは最初の1文字だけでこれらを区別できることを発見して、そのような木を作る(2文字目以降で分岐することはない)。ハッシュテーブルはそういうことはせずに全部混ぜ込んで(=そのために時間まで浪費して)テーブルを引く。
タイムスタンプを変更しない
* acl2ライブラリ #2 -(by [[K]], 2022.11.24) ** (1) crit-bit木について #1 -''[1-1]'' -acl2ライブラリには、ACrit32L0B1などのクラスがあって、これはcrit-bit木を利用するためのクラスです。それでまずはcrit-bit木について説明します。 -''[1-2]'' -何か適当なオブジェクトの集合があって、それらを高速に検索したいとします。ソートした結果をポインタの配列で持っておくことができたら、あとは二分探索で探すだけでよくなるので、とても便利だと思います。これならO(logN)です(もしソートしてなかったら二分探索が使えないので、O(N)になってしまいます)。 -ソートといえばqsort()でしょう。ということで、ひとまず解決です。 -・・・でも待ってください。もし今100万件のデータがソート済みで、そこから一つだけデータを消すとしたらどうでしょうか?まず消すべきデータがどこにあるのかは二分探索ですぐにわかります。そして、そこを一番最後の要素で上書きすれば、これで一つ消したことになります。しかしこれだとソートされた状態が維持できていません。仕方ないのでqsortを呼びます。・・・これはいけません。O(NlogN)の時間がかかります。 -それならmemmove()で消したい場所以降を1つ分だけ前にずらすのはどうでしょうか。これならO(N)で済みます。qsort()よりもいいです。・・・同じ問題は、データを1件だけ追加するときにも生じます。二分探索で挿入すべき場所を探して、memmove()でそこに1つ分の空きを作って入れるやり方のほうがqsort()よりも速いです。・・・結局qsort()は最初にたくさんのデータをソートするには最適なアルゴリズムですが、その後1件ずつ更新するには不向きなアルゴリズムです。 -''[1-3]'' -しかしO(N)というのはお世辞にも速いアルゴリズムだとは言えません。・・・これより速いアルゴリズムがあります。それば二分木やB木などです。これらはソートされた結果をポインタの配列で保持するのをやめて、「木」と呼ばれるデータ構造で管理します。この状態でもデータの検索はO(logN)で行えます。またデータの削除や追加はそれぞれどちらもO(logN)で、先のO(N)よりも格段に速いです。ただし、単純な配列よりもメモリは使います。たいてい単純な配列バージョンの数倍の容量を必要とします。ですから開発者はスピード重視かそれとも省メモリ重視なのかで使い分けることになります。 -''[1-4]'' -とはいえ、人類のアルゴリズムの探求は終わりません。もっと速くできないでしょうか。・・・結局、二分木やB木の処理時間のほとんどは、キーデータの比較です。そう、単純な配列であっても二分探索の際にはO(logN)回のキーの比較をやっていました。だからこの比較処理をやめることができたら、ずっと速くできそうです。・・・そんな夢みたいな方法があるのか・・・あるんです。 -たとえば、キーのデータが、必ず大文字のアルファベット4文字で構成されているとしましょう。これは26^4=45.7万通りしかありません。それなら45.7万の要素を持つポインタの配列を作ればいいじゃないですか。これで、キーの値が"KAWA"のオブジェクトがどこにあるかをO(1)で検索できるようになります。データの1件ずつの追加も削除もすべてO(1)です。これはもはやハッシュ関数を使わないハッシュテーブルです。 -しかしまあ、実際はそんな「キーの値は45.7万通りしかない」みたいなことはまれで、たいていはテーブル配列をメモリ上に構築できません(大きすぎて無理)。でも要点は一つ見つかりました。「ほかのキーとの比較をやめればいい」これです。 -たとえばこうです。"KAWAI"と"OSASK"と"ESSEN"の3つのキーがあるのなら、1文字目だけ見ればそれだけでどこに行けばいいのかわかります。もしここで"OMAKE"も入って合計4つのキーになった場合は、1文字目が"O"の場合に限って2文字目を見て、"S"か"M"かで分岐します。こういう「木」を作ればいいのです。・・・この木をたどる際には、自分のキーだけ見ていればいいのです。他のキーとの比較は発生しません。・・・こういう木を「トライ木」と総称します。 -''[1-5]'' -先の例ではキーの文字に注目しました。文字だと分岐が多くなります。仮に1文字が1バイトだとしても256通りもあります。そんな木は管理が大変なので、キーをビット列に展開し、1ビットずつで見ていくことにして、3ビット目が"0"ならこっち、"1"ならあっち、みたいな木を作ります。これがcrit-bit木です。crit-bitというのは「クリティカル・ビット」の略らしいです。これは私の推測ですが、臨界点(クリティカル・ポイント)という言葉があって、水と氷のさかい目の温度とか、水蒸気と水のさかい目の温度などのことを指しているのですが(というか物質は水に限らないし、温度じゃなくて圧力の場合も臨界点なのですが)、そういう、AかBかのぎりぎりのさかい目ということで、クリティカル・ビットなのかなーと。1ビットで枝分かれするのであれば、分岐はそれぞれ2つしかありません。二分木とにたような木になって理解しやすいです。木をたどるときに他のキーとの比較はないので、crit-bitはトライ木のうちの一つです。 -''[1-6]'' -crit-bit木に関するリンク集です。 --https://dankogai.livedoor.blog/archives/51853853.html --https://cr.yp.to/critbit.html --https://qiita.com/Pseudonym/items/c2ef9a3a574b9e047287 ** (2) crit-bit木について #2 -''[2-1]'' -ここで、ベンチマーク結果を示したいと思います。・・・0から999999まで、数を数えて文字列にします。これで"0", "1", "2", "3", ... という100万件の文字列データができます。これをqsortでソートします。その際に比較関数を int qscmp(const void *a, const void *b) { return strcmp(*(char **) a, *(char **) b); } -としてみました。すると当方の環境(Kano PC, Celeron N4000)では、初期の並びを配列に書き込んでqsort()が終わるまでに340ミリ秒かかりました。ソート結果を出力するとこうなっていました。 0 1 10 100 1000 10000 100000 100001 100002 100003 100004 100005 100006 100007 100008 100009 10001 100010 100011 100012 -いい感じに混ざり合っているのがよくわかります。次に、比較関数を改良しました。strcmp()は遅いので、使わない形に書きました。 int qscmp(const void *a, const void *b) { const unsigned char *ca = *(const unsigned char **) a, *cb = *(const unsigned char **) b; if (ca[0] != cb[0]) return ca[0] - cb[0]; if (ca[1] != cb[1]) return ca[1] - cb[1]; if (ca[2] != cb[2]) return ca[2] - cb[2]; if (ca[3] != cb[3]) return ca[3] - cb[3]; if (ca[4] != cb[4]) return ca[4] - cb[4]; if (ca[5] != cb[5]) return ca[5] - cb[5]; return ca[6] - cb[6]; } -こうすると、286ミリ秒になりました。もちろん結果は同じです。ではもっと改良します。32bitのintを使って、4文字ずつ比較します。 int qscmp(const void *a, const void *b) { const unsigned int *ca = *(const unsigned int **) a, *cb = *(const unsigned int **) b; if (ca[0] != cb[0]) return ca[0] - cb[0]; return ca[1] - cb[1]; } -この比較を成功させるためには、文字列を加工する必要があります。x86はリトルエンディアンなので、char *s;のs[0]が上位に来てくれないのです。それでエンディアンを入れ替える処理を追加しました。するとソートに要する時間は278ミリ秒になりました(このキーの加工にかかる時間はこの278ミリ秒には含まれません)。32ビット環境ではこれくらいがqsort()での限界かなと思います。 -''[2-2]'' -では同じことをACrit32L0B1でやってみました。100万件の文字列を作って、やはりキーを都合のいい形式に加工しておきます(ビットを入れ替える)。また空っぽの木も準備しておきます。ここから処理時間を計測開始です。ACrit32L0B1_ins()でデータを1つずつ追加していきます。合計100万件です。そしてその後、qsort()と結果が同じ形式になるように配列にポインタを出力させます。この時間を計りました。100万回のinsに186ミリ秒かかって、配列への出力に20ミリ秒かかりました。合計で206秒です。qsort()での最速の278ミリ秒よりも明らかに速いです! -''[2-3]'' -話はこれで終わりではありません。実はACrit32L0B2とACrit32L0B4というクラスもあります。使い方は同じなのですが、速さが違います。この違いは何かというと、~B1は1ビットずつのcrit-bit木なのですが、~B2は2ビットずつのCrit-bit木なのです。~B4は4ビットずつのcrit-bit木です。結果を以下にまとめておきます。 |qsrot #1|RIGHT:340ms| |qsort #2|RIGHT:286ms| |qsort #3|RIGHT:278ms| |ACrit32L0B1|RIGHT:186+20=206ms| |ACrit32L0B2|RIGHT:145+17=162ms| |ACrit32L0B4|RIGHT:127+15=142ms| -このようにこの例ではqsort()よりも高速になりましたが、他のキーに対してはまた別の結果になるだろうと思います。またC++のSTLのsortはqsort()よりも3~4倍くらい速いという話を見聞きしたことがあるので、ACrit32はそれには負けていると思います。やっぱり高速に1件ずつ追加したり削除したりできることを優先していると、まとめて一気にソートするアルゴリズムには勝てないです、たぶん。でもその差はもうあまり大きくはないようです。 -''[2-4]'' -上記の例では文字列は最長でも6バイトでした。もっと長い文字列でやったら当然遅くなると思いますが、さてどの程度遅くなるでしょうか。実験してみました。文字列はUTF8で全角文字にしました。これで1文字当たり3バイトになるので、最長18バイトになります。 -qsortの比較関数はこうしました。 int qscmp(const void *a, const void *b) { const unsigned int *ca = *(const unsigned int **) a, *cb = *(const unsigned int **) b; if (ca[0] != cb[0]) return ca[0] - cb[0]; if (ca[1] != cb[1]) return ca[1] - cb[1]; if (ca[2] != cb[2]) return ca[2] - cb[2]; if (ca[3] != cb[3]) return ca[3] - cb[3]; if (ca[4] != cb[4]) return ca[4] - cb[4]; return ca[5] - cb[5]; } -結果は以下の通りです。 |qsort|RIGHT:328ms| |ACrit32L0B1|RIGHT:203+21=224ms| |ACrit32L0B2|RIGHT:175+18=193ms| |ACrit32L0B4|RIGHT:160+16=176ms| //-qsortでは328/278=1.18倍になりましたが、ACrit32L0B4では176/142=1.24倍になりました。 -''[2-5]'' -ではキーをもっと短くしたらどうでしょうか。今回は最長でも数字が6ケタなので、これをBCD(2進化10進数)という方法で表現すれば32bitに収まりそうです。BCDは簡単に言うと、数字1ケタを4ビット(=0~15)で表現する方法です。だだし今回は必要な文字として0~9のほかにスペースもあるので、スペースを0として、0~9を1~10に対応させて変換しています。 -qsortの比較関数はこうしました。 int qscmp(const void *a, const void *b) { const unsigned int *ca = *(const unsigned int **) a, *cb = *(const unsigned int **) b; return ca[0] - cb[0]; } -一方でACrit32のほうも、キーの長さが32bitの固定長なら、より高速なモードがあります。それでいろいろ試しています。結果は次の通りです。 |qsort|RIGHT:179ms|| |ACrit32L0B1|RIGHT:176+22=198ms|可変長モード| |ACrit32L1B1|RIGHT:153+22=175ms|固定長32bitキー専用クラス| |ACrit32L0B2|RIGHT:137+15=152ms|可変長モード| |ACrit32L1B2|RIGHT:120+15=135ms|固定長32bitキー専用クラス| |ACrit32L0B4|RIGHT:112+11=123ms|可変長モード| |ACrit32L1B4|RIGHT:102+11=113ms|固定長32bitキー専用クラス| -ちなみにACrit32L0Bxには固定長モードもありますが、結果は可変長モードとほぼ同じでした。 ** (3) crit-bit木について #3 -''[3-1]'' -acl2ライブラリが提供するACrit32LxBxクラスは以下の機能を持ちます。 |init|初期化| |deinit|後始末| |ins|データの追加| |srch0|検索(キーを指定):データを返す| |srch1|検索(ソート順でn番目):データを返す| |srch2|検索(キーを指定):nを返す(註1)| |srch3|検索(キーを指定):nを返す(註1)| |srch4|srch0の連続指定(註2)| |srch5|srch1の連続指定(註2)| |del0|削除(キーを指定):データを返す| |del1|削除(ソート順でn番目):データを返す| |annl|キーの加工(アニーリング)(註3)| --[註1] srch2とsrch3の違いを説明します。srch2が木の中にあるデータをキーで指定して、それが何番目かを問い合わせるためのものです。だからキーが見つからない場合、-1を返します。srch3はキーが木の中にあればsrch2と同じ挙動ですが、もしない場合は、「仮にそのキーを今木にinsしたら何番目に入るか?」を考えてそのnを返します。・・・srch3の使い道ですが、たとえば、key=1000とkey=2000でそれぞれのnを出して、1000~2000の間にいくつのデータがあるかを数えたりすることができます。 --[註2] 指定されたキーから連続で複数のデータポインタを得られます。連続というのは、ソート順での連続です。srch0やsrch1を何度も呼ぶよりもはるかに高速です。 --[註3] キーを検索しやすい形に加工します。主にビットを並べ替えます。これをひとまずアニーリング(焼き入れ、焼きなまし)と名付けましたが、これは一般的な用語ではなく、私が無理やり考えた呼び名です。ins/srch/delでキーを指定するときは、必ずannl済みのもので指定する必要があります。 -''[3-2]'' -世間で一般的に知られているcrit-bit木は、srch1~5, del1のようなことができません。それはそれぞれのデータがソート順で何番目なのかをO(logN)で計算するための情報を持っていないせいです。でもACrit32はそれらの情報を持っているので、これらの問い合わせに応えることができます。その代わり、各ノードのサイズは 3 * sizeof (int) ではなく、 5 * sizeof (int) になっています(ACrin32LxB1の場合)。 ||B1|B2|B4| |pos:ビット位置|1|1|RIGHT:1| |ptr:ポインタ|2|4|16| |num:そのポインタの先にいくつデータがあるか|2|4|16| --numは実はアルゴリズムを工夫すれば、1,3,15にできることが分かっているのですが、処理を単純化して高速にするために、ポインタと同じ数だけ持たせています。 --num==1の時、対応するポインタはデータを指しています。num>=2のとき、対応するポインタはノードを指しています。 ** (4) crit-bit木について #4 -''[4-1]'' -これだけいろいろできれば、例えばこんなことができます。 -malloc/free処理を考えます。・・・mallocするときに、要求サイズにできるだけ近い空きブロックを高速に見つけたいです。それはACrit32を使えば簡単にできそうです。 -それでは性能を比べてみたいと思います。まずこんなプログラムを作りました。 #include <stdio.h> #include <stdlib.h> #include <time.h> int main() { int i, sz = 16 * 1024, n = 10; void **p = malloc(1024 * 1024 * sizeof (void *)), *q; free(malloc(1026 * 1024 * 1024)); // 1GBくらいを確保して解放(意図しない断片化を防ぐため). for (i = 0; i < n; i++) p[i] = malloc(sz); q = malloc(sz * 2); for (i = 1; i < n; i += 2) free(p[i]); clock_t t0 = clock(); for (i = 0; i < 1000000; i++) { free(q); free(p[0]); p[0] = malloc(sz); q = malloc(sz * 2); } clock_t t1 = clock(); printf("%f[sec]\n", (double) (t1 - t0) / CLOCKS_PER_SEC); return 0; } -最初に16KBのメモリ領域をn個確保します。次に32KBのメモリ領域を1個確保します。そしてn/2個の16KBのメモリ領域を解放します。このときにメモリの空きがとびとびになるようにします。これで準備完了です。 -ここで free(q); free(p[0]); p[0] = malloc(16KB); q = malloc(32KB); を実行します。これは解放してから確保し直すという、かなり無意味なことをやっています。でもこの処理をK&R型のmalloc()はかなり苦手としています。一番時間がかかるのは q = malloc(32KB); のところです。n/2個の16KBしかない空き領域を読み飛ばして、やっと32KB以上の空きメモリを見つけることができるのです。ここでは n=10 なので大したことはないのですが、nが大きくなると結構な負担になります。 -ただし、それでも1回やったくらいでは大して時間はかからないので、100万回繰り返して、かかった時間を計ります。 -次にACrit32版のほうについてです。以下のようなクラスを作り、initして、本家のmalloc()で1GBくらいを取ってきて、MemManの管理下に組み入れます。あとはすべてMemManに対するmalloc/freeに書き換えるだけです(条件を同じにしないと比較にならないので)。 AClass(MemMan) { ACrit32L0B1 idxSiz; // 空きメモリブロック情報オブジェクトの、sizメンバをキーとするインデックス. }; -mallocではidxSizを使って最適な空きメモリブロックをO(logN)で見つけてきます。見つけて必要なメモリを切り取った後に余りが出れば、それはidxSizに登録します。・・・freeでは、前後の領域とマージできるときはマージして、それからidxSizに登録します。 ||n=10|n=65536| |オリジナル(msvcrt.dllのmalloc/freeを使用)|RIGHT:599ms|RIGHT:4178ms| |ACrit32L0B1を使ったmalloc/free|RIGHT:279ms|RIGHT:533ms| |ACrit32L0B2を使ったmalloc/free|RIGHT:293ms|RIGHT:427ms| |ACrit32L0B4を使ったmalloc/free|RIGHT:399ms|RIGHT:493ms| -これを見ると、「ACrit32L0B1を使ったmalloc/free」がシンプルで一番よさそうに思います。n=10の時点で、すでに標準のmalloc/freeの2倍の速度が出ています。これで、ACrit32L0B1の基本的なオーバヘッド(Nによらないオーバーヘッド)は十分に小さいことが分かります。n=65536では性能の差は圧倒的になっています。 -高性能なmallocとしてはdlmallocが知られていますが、このMemMan-mallocはサイズごとに処理を分けるようなこともなく、ごく単純にK&R型のmallocを速くしたような感じになっています。 -なお、言うまでもないですが、メモリ消費量の観点では、crit-bit木の分も必要になるため、K&R型と比べれば劣っています。 ** (5) crit-bit木について #5 -''[5-1]'' -ACrit32L0B1などでは、データオブジェクトの中にアニール済みのキーデータを保持する必要があります。しかしcrit-bit木に登録するために、符号付き整数の符号ビットを反転させたり、浮動小数点形式も加工することになるので、アニールを解いてそれから元に戻すことも容易ではありますが、不便です。そうなるとキーのためのデータと元のデータの両方を保持することになり、メモリ消費量の観点からは好ましくありません。 -そのためオプションとして、若干の速度低下はあるものの、crit-bit木のためのキーデータを一切保持しないで済むモードを付けました。このモードでは必要に応じて無加工のデータからcrit-bit木のためのキーデータを生成します(そういう関数を用意してください、クラスから必要に応じて呼び出します)。 -そもそもcrit-bit木では、キーデータが必要になるのは、データ追加1回につき、2回だけです(追加するデータのキーで1回、登録済みデータのうち、crit-bit木のアルゴリズムで選ばれたデータのキーで1回)。検索1回についてもその程度ですし、削除1回についでもその程度です。ですからqsortの比較関数と比べると、呼ばれる頻度はずっと少ないです。 -''[5-2]'' -結局、crit-bit木の使いどころはどこなのかと考えました。 --[1]ハッシュテーブルの代わりに使う: ハッシュテーブルはうまくいっているときは確かにO(1)(というか厳密にはO(m)ですが)なんですが、衝突が起きてしまうと性能は急激に悪くなってしまいます。つまりワーストケースの性能はかなり悪いわけです。一方でcrit-bit木はベストでもワーストでもO(m)なので、とても安定しています。ただまあポインタをたどる処理は処理的には少し重いので、衝突がほとんどないときのハッシュテーブルの速度は出ないだろうとは思います。でもcrit-bit木にすれば、ハッシュ関数をどうしたらいいかとか、テーブルサイズを動的に拡大縮小するかどうかなどを悩む必要は一切ありません。ハッシュテーブルのオープンアドレス法では、データを消すのは削除マークを入れるしかないですが(ハッシュテーブルを作り直すこともできますが)、crit-bit木ではちゃんと消せて性能が上がります。 --[2]平衡木の代わりに使う: 性能を比較してないので断言できないのですが、よほどキーの長さが長くない限りは、crit-bit木が性能で大きく負けることはなさそうな気がします。メモリ消費量の点からも他の平衡木に比べて何ら見劣りすることはありません(ACrit32は、srch1やsrch3を可能にするために冗長になっていますが)。ディスク上の木構造に有利と言われているB木に勝てるかどうかはわかりません。でもなんか工夫したら、いけそうな感じはします。 --[3]全文検索に使う: 数万字、数百万字、なんならそれ以上のテキストがあったとして、それを先頭から1文字ずつ削りながら、すべてcrit-bit木に登録したとします。ここで探索したい文字列で検索すれば、O(logN)くらいで見つけられると思います。この用途のためにはsuffix-arrayやLCP-arrayが有名ですし、そのほうが消費メモリは少なくて済みますが、しかしこれらは後から検索対象テキストを少量ずつ追加するのには向いていません。一方で、crit-bit木や平衡木なら、少量ずつの追加に十分に対応できます。 -''[5-3]'' -ハッシュテーブルとcrit-bit木での性能比較をやってみました。 -テストに使ったのは、適当なHLX向けのサンプルプログラムをつないで一つのテキストファイルし、それをlexerでトークンに分けて、さらにトークンが1文字以下のものは捨てて2文字以上のものだけにして、トークン文字列をトークン番号に変換する処理を、10万回繰り返す処理です。1文字のトークンを避けたのは、「1文字の場合はサイズが256の配列を作ってそれで一発で求める」という処理を書くと思うので、それ以外の長さのトークンをいかに速く処理できるかを見たかったためです。なお、文字列リテラルには極端に長いものもあって処理が面倒になってきたので、それはすべて"str"と書いてあるとみなして処理しています。結果的にトークンはすべて2~16文字になりました。 -ACrit32L0Bxでは、すべてのキーは16バイトの固定長であるとしました。そうするほうが簡単だったので。ハッシュテーブルのほうは、ハッシュ関数にFNVの32ビット版を使っています。Hash 582 はハッシュテーブルの大きさが582ということです。582は1bitのcrit-bit木を限界まで削ったときと同じ消費メモリの大きさのハッシュテーブルです。 |ACrit32L0B1|RIGHT:1425ms|| |ACrit32L0B2|RIGHT:999ms|| |ACrit32L0B4|RIGHT:746ms|ACrit32ではこれが最速| |hash 582|RIGHT:879ms|| |hash 512|RIGHT:839ms|| |hash 1024|RIGHT:812ms|ハッシュ関数をカスタマイズしないのならここが限界| -うーんハッシュテーブルのパワーとはこんなものだったでしょうか?そんなことはないはず!と思って改良しました。ハッシュ関数の計算を16バイト固定にはしないで、長さが短いときは適当に打ち切ることにしました。これでも今回の用途に限れば問題ないはずです。 |ACrit32L0B1|RIGHT:1425ms|| |ACrit32L0B2|RIGHT:999ms|| |ACrit32L0B4|RIGHT:746ms|ACrit32ではこれが最速| |hash 582|RIGHT:879ms|| |hash 512|RIGHT:839ms|| |hash 1024|RIGHT:812ms|ハッシュ関数をカスタマイズしないのならここが限界| |hash2 582|RIGHT:493ms|| |hash2 512|RIGHT:453ms|| |hash2 1024|RIGHT:413ms|ハッシュ関数をカスタマイズすればここまでいける| -ハッシュテーブルに関しては、テーブルの大きさを4096や65536なども試しましたが、そもそも1024の段階でほとんど衝突していないようで、性能向上は見られませんでした。むしろキャッシュヒット率が下がってきて、性能低下するくらいです。 ** (6) crit-bit木について #6 -[考察]Crit32L0B1とCrit32L0B4で何が違うか? --[1]最大で4倍速く枝をたどれる(2つしか入っていないときは1倍) --[2]16分岐すべてが埋まっていれば・・・B1だと5*sizeof(int)*15, B4だと33*sizeof(int)。だから0.44倍くらい省メモリになる。 -[考察]ハッシュテーブルに対する優位性は? --たとえば、Alfa, Bravo, Charlie, Delta, Echoみたいなキーだった場合、crit-bitは最初の1文字だけでこれらを区別できることを発見して、そのような木を作る(2文字目以降で分岐することはない)。ハッシュテーブルはそういうことはせずに全部混ぜ込んで(=そのために時間まで浪費して)テーブルを引く。
テキスト整形のルールを表示する