Posted at

数GB以上の巨大なXMLをPythonで読むときのメモリ節約法

More than 1 year has passed since last update.


はじめに

機械可読なデータをやり取りするときはJSONが主流になって久しい世の中ですが、XMLでデータが配布されることは時々あります(古くからある機関が公開してくれているデータとか)。

あるいは自然言語処理をしていると、たとえば構文解析機の CaboCha には解析結果をXML形式で出力するオプション (-f 3) があるので、結果の処理がいわゆる lattice の形式より楽になるという意味で使ったりすることがあるのではないでしょうか。

私は後者のクチで、大きめのコーパスの構文解析結果をXMLに落としてあれこれしようとしていたのですが、その8GBのXMLを手元にあるメモリ64GBのマシンで処理しようとしたところ、メモリがいっぱいになって途中から進まなくなってしまいました(エラーすら吐かない)。

がんばってメモリを増やしたぞーというつもりで64GBにしたのでちょっとびっくりしました。

問題のXMLは、<root> のタグの下に、<item> というタグが何個もぶら下がっている、リスト形式状のものです。レコード形式ということもあるようです。

<root>

<item>...</item>
<item>...</item>
...
<item>...</item>
</root>

一つ一つの item を処理するときは他の item とは無関係で、順々に見ていけたらよいものとします。そういうタイプのデータが巨大なときには iterator (generator) を使うとメモリにやさしいというのは多くの方がご存知かと思います。

もちろん XML を扱うライブラリにも、XMLファイルを iterator で読み込めるメソッドはあるのですが、ちょっとコツが必要でした。


Python 標準ライブラリ で XML

Python で XML を処理するときは標準の xml.etree.ElementTree を使うのが手軽です。他にも有名ドコロで BeautifulSoup というのもありますが、HTMLに特化している関係で私が扱いたいXMLでは解析エラーになる箇所があり1、ハマってしまったので標準ライブラリに落ち着いています。

本稿ではこの標準ライブラリ xmliterator なXMLパースをするときの注意について述べます。


ふだんの使い方(全部メモリに入れる)

iterator とかしないで普通に使うときはこうです。

import xml.etree.ElementTree as ET

tree = ET.parse('path/to/xml')

for item in tree.iterfind('item'):
# do something on item

.iterfind()でXMLツリーのなかの <item> タグを iterator しながら読み込んでいますね。でも、その直前の ET.parse() はいうなれば file.readlines() みたいなものです。メモリをたくさん食べます。


iter するとき(でもメモリは食べる)

iter しながら読みたいときはこうです。

import xml.etree.ElementTree as ET

context = ET.iterparse('path/to/xml')

for event, elem in context:
if elem.tag == 'item':
# do something on item

ET.parse()ET.iterparse() にすると、引数のパスにあるXMLを iterator 形式で読み込みます。1タグごとに読んでいくのですが、タグの終わりに差し掛かったときにだけ contexteventelem を返します。event == "end"で、elemは エレメントですね。

これで省メモリに処理ができる!と思ったら大間違いなのです。実は # do something on itemのところがpassであっても、「ふだんの使い方」と同じくらいのメモリを使います

iterしながらも、それまで読んだタグを context はすべて保存しているんです

どこかというと、context.root というローカル変数が iterator の中に隠れているんです。そんなことは公式ドキュメントにすら書いてなかったので知りませんでした。普通の generator と違って、あとから繰り返しアクセスできるという意味ではうれしい人もいるかもしれないでしょうか。まぁXMLの入れ子構造を読みながら保持するためにはそういう仕組みも必要そうなのは言われてみれば想像できます。


iter するとき(メモリを食べない)

ではどうすればよいかというと、その昔 ElementTree という名前のライブラリとして Python 2.5 で標準に組み込まれる前までの公式ページに Tips がありました。Pythonは3からの新参者だったので全然しりませんでした。

import xml.etree.ElementTree as ET

context = ET.iterparse('path/to/xml', events=('start', 'end'))

_, root = next(context) # 一つ進めて root を得る

for event, elem in context:
if event == 'end' and elem.tag == 'item':
# do something on item
root.clear() # 用が済んだらrootを空に

ET.iterparse()にはeventsというキーワード引数を指定できて、これに'start'を指定すると開きタグを教えてくれるんだそうです。最初の開きタグは<root>なので、これを変数にとっておきます。このとき_で捨てている値には件の文字列'start'が入っています。

root さえ取ってしまえば2、都度中身を.clear()すればエレメントの情報がメモリから退散します。めでたいですね。






  1. HTMLで予約されている <link /> などの単独タグがXMLで使われていると、中にテキストがあっても消されてしまう。対処法はたぶんあったけどうまく行かなった記憶があります。 



  2. 一昔前のAndroidみたいな響きで素敵ですね。