【MarkLogic Server】テキスト出力するCoRBのチューニング考察

はじめに
===
今回は、簡単な例を通じてCoRB( https://github.com/marklogic-community/corb2 )のチューニング方法を検証していきたいと思います。
なお、XQuery/CoRBについてある程度理解している前提でお話を進めますので、ご存知の無い方はgithubのページなどを一読してからお願いします。

背景
===
CoRBは並列処理によって高速化を図ってくれるわけですが、それでも処理データが大量にあるとチューニングの必要性が出てきます。一番効果的なのは、スケールアップ(ディスクをHDDからSSDにする、CPU増強など)やスケールアウト(処理ノードの追加、クラスタリングなど)ですが、これらは予算の問題もありますね。
そこで、まずはプログラミングの観点から改善を図っていこうと思います。

今回検証するチューニング方法
===
CoRBはJavaマルチスレッドプログラムであり、チューニングの対象はパラメータの設定と、クエリの書き方ということになります。
検証対象を以下にまとめました。

| 対象項目 | 検証内容 |
|---|---|
| スレッド数 | CoRBがMarkLogicに投げるスレッドの数を示すパラメータ「THREAD-COUNT」の最適値を探る |
| バッチサイズ | 並列処理単位である個々のPROCESSクエリに引き渡す入力数のパラメータ「BATCH-SIZE」の効果と最適値を探る |
| XQuery | 並列処理されるPROCESSクエリに対する修正効果を探る |

スレッド数とバッチサイズは、CoRBをJavaコマンドで実行する際のパラメータです。デフォルトはいずれも1です。バッチサイズを設定することで複数のデータへの処理を1個のトランザクションで行うことができるようになります。バッチサイズの設定はクエリの修正を伴います。(後述)

題材とするクエリ処理は、MarkLogic内のXMLファイルをCoRBのテキスト出力オプションを用いてCSVファイルとして出力するものとしました。データはダブルクォーテーションで囲まないものとします。

実機検証環境・検証条件
===

では実際にサンプルデータと、URI/PROCESSクエリを入れてCoRBを実行してみましょう。

まず、実施環境は以下の通りです。

| 対象MarkLogicバージョン | OS | CPU | メモリ |
|---|---|---|---|
|8.0-3.3|Windows7<br>Professional|Intel(R)Core(TM)i7-3740QM CPU2.70GHz 2.70GHz<br>(4core8スレッド)|16GB|

サンプルデータは、1XMLに必ず50要素です。key01～key50まで順番に並んでいます。各データは、それぞれ異なる20文字の要素値を持ちます。つまり空要素や要素の抜け落ちはありません。これを100万XMLファイル分作成しました。

```
<testDocument>
    <key01>Tjr5v8Ye_000000000001</key01>
    <key02>0ZCIjYAg_000000000001</key02>
    <key03>EdnTwU9F_000000000001</key03>
    ・・・ (47要素) ・・・
</testDocument>    
```

100万ファイルは、MarkLogic内の/dataSize/ディレクトリに全て格納します。これをCSV出力するCoRB実行ファイル、URIクエリとPROCESSクエリは以下のような感じです。
☆実行ファイルサンプル

```
java -cp .;"MarkXCC.Java-8.0-3\lib\marklogic-xcc-8.0.3.jar";"MarkXCC.Java-8.0-3\lib\marklogic-corb-2.1.3.jar" ^ 
-DMODULE-DATABASE="corbTestModule" ^ 
-DMODULE-ROOT="/" ^ 
-DXCC-CONNECTION-URI="xcc://admin:admin@localhost:8062/" ^ 
-DPROCESS-MODULE="/corb/Default_PROCESS.xqy" ^ 
-DURIS-MODULE="/corb/Default_URIS.xqy" ^ 
-DTHREAD-COUNT=4 ^ 
-DINSTALL="false" ^ 
-DPROCESS-TASK="com.marklogic.developer.corb.ExportBatchtoFileTask" ^ 
-DEXPORT-FILE-NAME="C:\work\test.csv" ^ 
com.marklogic.developer.corb.Manager
```


☆URIクエリ

```
xquery version "1.0-ml";
let $dir := "/dataSize/"
let $uri-seq := cts:uris((), (), cts:directory-query($dir, "1"))
let $count := fn:count($uri-seq)
return ($count, $uri-seq)
```
☆PROCESSクエリ①($element-seqへの代入は長くなるので省略)
なお、今回はどの要素も値が入っている前提で組んでいますが、実際のデータは要素そのものが無かったり空要素だったりするので、その場合は適切に条件分岐を入れて下さい。

```
xquery version "1.0-ml";
declare variable $URI xs:string external;
let $elementName-seq := ("key01", "key02", ..., "key50")
let $doc := cts:search(/*, cts:document-query($URI))
let $text-seq := 
  for $i in $elementName-seq
    return $doc/*[fn:name() = $i]/text()
return fn:string-join($text-seq, ",")
```
(1)THREAD-COUNTを対象としたチューニング
===
CPU数を鑑みて、THREAD-COUNT=4からスタートしました。TPS(Transactions Per Second) とは、1秒当たりのトランザクション処理件数を指す単位であり、高ければ高いほど性能が良いことになります。このTPSはCoRBが標準出力したものです。

| THREAD-COUNT | 処理時間[秒] | TPS   |
|---           |---          |---    |
| 4            | 936         | 1,068 |
| 8            | 748         | 1,340 |
| 16           | 726         | 1,377 |
| 32           | 711         | 1,407 |
| 64           | 710         | 1,409 |

CPUが8スレッドということもあり、THREAD-COUNT=4より8の方が良さそうです。8→64でも若干改善していますが、そこまで劇的ではないようです。まずはCPUスペックを軸に設定するのが妥当ですね。
他の検証に入るにあたり、あまりスレッド数を上げるのもどうかと思うので、以後の検証はTHREAD-COUNT=16を軸にやっていきたいと思います。

(2)BATCH-SIZEを対象としたチューニング
===
実行ファイルの中に「-DBATCH-SIZE=...」というパラメータを加えてみます。この方法をとったとき1個のPROCESSクエリにはURIクエリの結果のうち指定数がセミコロンで文字列結合されて渡されることになります。
このため、PROCESSクエリを以下のように修正しました。

☆PROCESSクエリ②
ポイントは複数のURIクエリ結果が入った$URI-seqでループを組まず、これをcts:searchの検索条件として使ったうえで、その検索結果でループを組んでいる点です。これにより検索関数cts:searchの総実行回数を減らすことができます。

```
xquery version "1.0-ml";
declare variable $URI xs:string external;
let $elementName-seq := ("key01", "key02", ..., "key50")
let $URI-seq := fn:tokenize($URI, ";")
let $doc-seq := cts:search(/*, cts:document-query($URI-seq))
for $doc in $doc-seq
  let $text-seq := 
    for $i in $elementName-seq
      return $doc/*[fn:name() = $i]/text()
  return fn:string-join($text-seq, ",")
```
では、THREAD-COUNT=16にて、バッチサイズを色々と変更してみましょう。なおBATCH-SIZE=1は、(1)の結果をそのまま持ってきています。

| BATCH-SIZE | 処理時間[秒] | TPS   |
|---          |---          |---    |
| 1           | 726         | 1,377 |
| 5           | 631         | 1,585 |
| 10          | 623         | 1,608 |
| 50          | 649         | 1,591 |

BATCH-SIZE設定は10%以上の改善を示しました。一番良さそうなのはBATCH-SIZE=10のようです。現実に適切な値の探求は少し大変そうですが、「処理対象のXMLファイルのサイズは10～100KBが良い」というMarkLogic社のサイト( https://docs.marklogic.com/guide/cluster/scalability )の教示から、今回のXMLファイルが1個1.74KB程度だったことを考えると6～60の範囲から始めるとよいような気がします。

(3)XQueryを対象としたチューニング
===
最後に、XQueryのチューニングを考えてみましょう。URI/PROCESSクエリ共に単純なので難問でしたが、必ず要素に値があるというデータについての仮定を利用してPROCESSクエリを修正してみました。THREAD-SIZE=16、BATCH-SIZE=10です。

☆URIクエリ
今回は数秒で終わってしまいますが、対象が多くてfn:countの時間がかかるときはcts:count-aggregateを使うと高速です。第一引数にはcts:uri-referenceを使いましょう。また、PROCESSクエリに回す必要のない処理はURIクエリで止めましょう。この他、PROCESSクエリで共通に使う変数をURIクエリで一括設定することも可能です。

☆PROCESSクエリ③
まずは、XPath表現「 * 」を取り除いてみましょう。cts:search内はデータのルート要素名、$doc/*の部分はXPath表現の「element()」にしてみました。

```
xquery version "1.0-ml";
・・・
let $doc-seq := cts:search(/testDocument, cts:document-query($URI-seq))
  ・・・
      return $doc/element()[fn:name() = $i]/text()
  return fn:string-join($text-seq, ",")
```
☆PROCESSクエリ④
MarkLogicにはXPathを変数によって表現するためにxdmp:valueという関数があります。XPathの表現をこれにしてみます。

```
xquery version "1.0-ml";
declare variable $URI xs:string external;
let $elementName-seq := ("key01", "key02", ..., "key50")
let $path-seq :=
  for $i in $elementName-seq
    return fn:concat("$doc/", $i, "/text()")
let $URI-seq := fn:tokenize($URI, ";")
let $doc-seq := cts:search(/*, cts:document-query($URI-seq))
for $doc in $doc-seq
  let $text-seq := 
    for $i in $path-seq
      return xdmp:value($i)
  return fn:string-join($text-seq, ",")
```
☆PROCESSクエリ⑤
ポイントは、出力対象がすべて同じ兄弟ノードであることを踏まえて、出力対象すべてを一旦$doc/*で取り出すようにしたものです。これらを一旦map変数に格納して、改めて変数elementName-seqを基に出力内容を作ります。この表現は対象データのほとんどの要素を出力する場合は良さそうです。

```
xquery version "1.0-ml";
declare variable $URI xs:string external;
let $elementName-seq := ("key01", "key02", ..., "key50")
let $URI-seq := fn:tokenize($URI, ";")
let $doc-seq := cts:search(/testDocument, cts:document-query($URI-seq))
for $doc in $doc-seq
  let $map := map:map()
  let $_ := 
    for $e in $doc/*
      let $name := fn:name($e)
      let $content := $e/text()
      return map:put($map, $name, $content)
  let $text-seq := 
    for $i in $elementName-seq
      return map:get($map, $i)
  return fn:string-join($text-seq, ",")
```

☆PROCESSクエリ⑥
このクエリは、PROCESSクエリ③が用いた前提に加えて、出力対象の要素が必ず順番どおりに存在するという前提も使います。

```
xquery version "1.0-ml";
declare variable $URI xs:string external;
let $URI-seq := fn:tokenize($URI, ";")
let $doc-seq := cts:search(/*, cts:document-query($URI-seq))
for $doc in $doc-seq
  return fn:string-join($doc/*/text(), ",")
```

結果はこんな感じです。

| クエリ         | 処理時間[秒] | TPS   |
|---             |---          |---    |
| PROCESSクエリ② | 623         | 1,608 |
| PROCESSクエリ③ | 1,592       | 628 |
| PROCESSクエリ④ | 1,356       | 738 |
| PROCESSクエリ⑤ | 442         | 2,264 |
| PROCESSクエリ⑥ | 396         | 2,532 |

意外なことに、PROCESSクエリ③④は芳しくありませんでした。ですがチューニング作業において、こういった経験はよくあると思います。
PROCESSクエリ②から⑤は30%改善しました。ただし、⑤は出力要素数が少ない場合は逆に不利になることは言及しておきます。
PROCESSクエリ⑥は最も良い数値となりましたが、今回のようにテキスト内の要素すべてを順番に出力する仕様でないと使えないものになってしまいました。

考察
===
THREAD-COUNTは、CPUスペックを軸にチューニングを開始すれば良いので、それほど悩まなくて済みそうです。ただし、複数のCoRBを同時に動かす可能性がある場合は注意がいるかもです。
BATCH-SIZEは、今回それほど性能向上がないように思えるのですが、個人的には処理時間が1/2～1/3になった経験もあり無視できません。
XQueryの修正はかなり効いたようです。結果論ですが、BATCH-SIZEの検証よりもXQueryの改善から取り組む方がよかったかもしれません。ただし、書いてみたクエリが却って悪くなっても落ち込まず、仕様とにらめっこして何度も修正を重ねてみてください。

おわりに
===
CoRBをチューニングする3つの方法について実際に検証してみて、その効果を実感することができました。CoRBを使うことがあれば念頭に置くと良いかもです。
皆さまが実際に取り組むときは、これとは違うデータ構造・データ量・処理内容・スペックetcになり、それによってチューニングによる効果も変わってくるかと思います。とにかく早いタイミングで性能試験を行うことを心がけましょう。


```math
\def\textsmall#1{%
  {\rm\scriptsize #1}
}
```

------------------------------------------------------------------------

**免責事項**

​​​​​$\textsmall{当ユーザ会は本文書及びその内容に関して、いかなる保証もするものではありません。}$
​​​​​$\textsmall{万一、本文書の内容に誤りがあった場合でも当ユーザ会は一切責任を負いかねます。}$
​​​​​$\textsmall{また、本文書に記載されている事項は予告なしに変更または削除されることがありますので、予めご了承ください。}$