TL;DR
-
encoding/csv
のReader.Read/ReadAllは一行全体から部分文字列で切り出して[]string
を返すので部分文字列が行全体文字列への参照を持っている - なのでReadの返値の一部だけをメモリに持つつもりでも行全部への参照を持ったままになってしまうので特にでかいCSVを読むときには気をつけよう
- メモリリークかと思ったらpprofで何でヒープを消費しているか調べてみるといいかも
英語版Wikipedia対応で発覚したCSV読み込みによるメモリリーク
PediaRoute というWikipediaの任意の2ページの間をリンクだけでたどっていけるかを検索するサイトを運営しています。
最近英語版Wikipediaも検索できるように改修したんですが、それによってメモリを馬鹿食いするようになって困っていました。
PediaRouteではWikipediaのページの情報のうちページタイトル、リンクデータ(読み込み位置とリンクの数)を起動時にCSVファイルから読み込んでいます。
CSVデータはこんな感じです。
5,false,アンパサンド,0,161,88970362,167
10,false,言語,161,292,88970529,2540
11,false,日本語,453,1252,88973069,25868
12,false,地理学,1705,278,88998937,922
14,false,EU_(曖昧さ回避),1983,27,88999859,2
...
左からページID、自動リダイレクトページかどうかの真偽値、タイトル、正リンク位置、正リンク数、逆リンク位置、逆リンク数です。
Wikpediaには2019年2月現在、日本語版で180万、英語版で1400万ページあります。1ページ1行対応のCSVファイルになっており、日本語版で105MB, 英語版で832MBあります。
やっぱり英語Wikpediaはデータでかいなあ、とか思ってそのときにはメモリ食いまくってることもほったらかしておいたんですが、そのあとしばらく経ってちまちまとメモリ削減してたところどうも計算よりメモリを消費していることに気づきました。
起動直後に強制GC runtime.GC()
呼んで runtime.ReadMemStats でHeapAlloc見てみたところ本来使うであろうメモリよりも大きい(読み込んでるファイルサイズより消費メモリのほうが大きい)ことに気づき、さすがにこれはなんかだめだ、ということで調査をはじめました。
pprofでのヒープ調査
Golangには pprof というくっそ便利なプロファイラが標準でついているので、まずはこれを使ってCSV読み込んで runtime.GC()
した後に何にそんなにメモリ食ってるのか見ることにしました。
pprofのページにある通り、 net/http/pprof
でHTTPサーバーを立てておき、 go tool pprof
で見てみました。
$ go tool pprof 'http://localhost:6060/debug/pprof/heap?debug=1'
Fetching profile over HTTP from http://localhost:6060/debug/pprof/heap?debug=1
Saved profile in /Users/user/pprof/pprof.alloc_objects.alloc_space.inuse_objects.inuse_space.001.pb.gz
Type: inuse_space
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) text
Showing nodes accounting for 3439.74MB, 100% of 3440.89MB total
Dropped 7 nodes (cum <= 17.20MB)
flat flat% sum% cum cum%
1504.58MB 43.73% 43.73% 1504.58MB 43.73% encoding/csv.(*Reader).readRecord
1140.78MB 33.15% 76.88% 1708.81MB 49.66% github.com/mtgto/pediaroute-go/internal/app/web.loadLowercaseTitleToIndices
794.38MB 23.09% 100% 1730.93MB 50.30% github.com/mtgto/pediaroute-go/internal/app/core.LoadPages
0 0% 100% 1504.58MB 43.73% encoding/csv.(*Reader).Read
0 0% 100% 3439.74MB 100% github.com/mtgto/pediaroute-go/internal/app/web.Load
0 0% 100% 3440.89MB 100% main.main
0 0% 100% 3440.89MB 100% runtime.goexit
0 0% 100% 3440.89MB 100% runtime.main
やっぱり本来使うであろうメモリよりも使用量が大きいこと、そして encoding/csv.(*Reader).readRecord
がかなりのメモリを持ったままになっていることに気づきました。なんで・・・?
メモリを食い過ぎるコード
この時点で私が書いてたコードはこんな感じです。読みやすくするためエラー処理だけ省いて載せてます。完全版はGitHubへのリンクをどうぞ。
type Page struct {
Id int32
Title string
IsRedirect bool
ForwardLinkIndex int32
ForwardLinkLength uint32
BackwardLinkIndex int32
BackwardLinkLength uint32
}
// in (CSVファイル) からPage構造体のスライスを読み込んで返す
func LoadPages(in string) []Page {
file, _ := os.Open(in)
defer file.Close()
reader := csv.NewReader(file)
records, err := reader.ReadAll()
if err != nil {
panic(err)
}
pages := make([]Page, 0, len(records))
for _, record := range records {
pageID, _ := strconv.Atoi(record[0])
pageIsRedirect, _ := strconv.ParseBool(record[1])
forwardLinkIndex, _ := strconv.Atoi(record[3])
forwardLinkLength, _ := strconv.Atoi(record[4])
backwardLinkIndex, _ := strconv.Atoi(record[5])
backwardLinkLength, _ := strconv.Atoi(record[6])
pages = append(pages, Page{
Id: int32(pageID),
Title: record[2],
IsRedirect: pageIsRedirect,
ForwardLinkIndex: int32(forwardLinkIndex),
ForwardLinkLength: uint32(forwardLinkLength),
BackwardLinkIndex: int32(backwardLinkIndex),
BackwardLinkLength: uint32(backwardLinkLength),
})
}
return pages
}
1行に7列あるCSVのうち、3番目以外はstrconvで数値/真偽値に変換していたんですが、 record[2]
だけ無変換でそのままPage構造体が持つようにしていました。
こんなコードでなんで encoding/csv.(*Reader).readRecord
がヒープをもっていると言われるのかわからず、csvのReaderのコードを読んでみました。
すると readRecord
内でパース中の行全体の文字列をもつsrcの部分文字列から返値を作っていることがわかりました。
// Create a single string and create slices out of it.
// This pins the memory of the fields together, but allocates once.
str := string(r.recordBuffer) // Convert to string once to batch allocations
dst = dst[:0]
if cap(dst) < len(r.fieldIndexes) {
dst = make([]string, len(r.fieldIndexes))
}
dst = dst[:len(r.fieldIndexes)]
var preIdx int
for i, idx := range r.fieldIndexes {
dst[i] = str[preIdx:idx]
preIdx = idx
}
原因はわかったので、encoding/csv.(*Reader).Read
の返値をディープコピーしてから持つようにすることで行全体の文字列を持ち続けてしまうことはなくなりました。実際の修正 (コミット)
$ go tool pprof 'http://localhost:6060/debug/pprof/heap'
Fetching profile over HTTP from http://localhost:6060/debug/pprof/heap
Saved profile in Type: inuse_space
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) text
Showing nodes accounting for 2735.02MB, 100% of 2735.02MB total
flat flat% sum% cum cum%
1149.29MB 42.02% 42.02% 1566.30MB 57.27% github.com/mtgto/pediaroute-go/internal/app/web.loadLowercaseTitleToIndices
842.03MB 30.79% 72.81% 842.03MB 30.79% github.com/mtgto/pediaroute-go/internal/app/core.CopyString (inline)
743.71MB 27.19% 100% 1168.72MB 42.73% github.com/mtgto/pediaroute-go/internal/app/core.LoadPages
0 0% 100% 2735.02MB 100% github.com/mtgto/pediaroute-go/internal/app/web.Load
0 0% 100% 2735.02MB 100% main.main
0 0% 100% 2735.02MB 100% runtime.main
readerのソースコメントには確かにひとつの文字列からスライスで切り出していることは書かれていますが、 encoding/csvのGodoc にはそのことが書かれておらず、私のようなプログラムを書くときに罠になってるっぽいなと思ったので本家にIssue投げてみたんですが、「(不要なメモリまで参照が残り続けることとのトレードオフで)速度優先でこういう実装になってる」との回答をもらいました。
バグではないとはいえフィールド数が多いCSVなんかだとメモリのムダが発生しやすくなるので、Readerに「CSVのフィールド値をスライスで切り出さない」というフラグを新たにもたせてもいいんじゃないのかなあと個人的には思ったりしました。
まとめ
-
encoding/csv
のReader.Read/ReadAllは一行全体から部分文字列で切り出すため返値が行全体文字列への参照を持っている。Readの返値の一部だけをメモリに持つつもりでも行全部への参照を持ったままになってしまうので特にでかいCSVを読むときには気をつけよう - メモリリークかと思ったらpprofでヒープを消費しているか調べてみるのがよい
PediaRoute は2GBメモリのVPS上で動かしてるんですが、さらにメモリを節約しないとスワップなしでは英語版Wikipediaを検索できないのでもうちょっと改修が必要そうです。
今回の話は詳しい人からしたら「数百MBのCSVはGoで読んじゃだめ」ってことかもしれないですが、Go初心者としてはpprof使っての調査をしてみたりと勉強になりました。
スライスの一部分を参照で持ってると全体が解放されないことは知ってたんですがGoの文字列も内部的にはUTF-8形式でのバイトのスライスなんですかね。csv readerの実装見るとそんな感じに見えるんですが、実はそこはちゃんとわかってません。誰か教えてください。