いろいろ備忘録日記

主に .NET とか Go とか Flutter とか Python絡みのメモを公開しています。

Goメモ-190 (Scanner を使った処理でのパフォーマンスTips)

概要

タイトルには Scanner とか書いているのですが、別段Scanner限定な訳ではないです。

bufio.Scanner 便利ですよねー。これ使ってファイルI/Oしている方多いと思います。

基本的にGoは処理速度が速いので、あまり気にならないと思いますが

とっても大きなファイル、例えば100万行あるテキストファイルとかを扱う場合は

少しでも速く処理してほしくなります。

そんなときに、ちょっこっとだけ役に立つかもしれないTipsです。

内容としては、どの言語でも同じことになりますが、以下となります。

  • 入出力はちゃんとバッファリングしましょう

Scannerを使ったサンプルとかで、以下のような形のものがよくあります。

// バッファリングしない状態でのファイルIOのサンプルです。
//
// REFERENCES
//   - https://stackoverflow.com/questions/64638136/performance-issues-while-reading-a-file-line-by-line-with-bufio-newscanner
//   - https://yourbasic.org/golang/temporary-file-directory/
//   - https://pkg.go.dev/os

package main

import (
    "bufio"
    "fmt"
    "log"
    "os"
    "time"

    "github.com/devlights/gomy/output"
)

func main() {
    var (
        fpath = "/tmp/try-golang-big.txt"
        file  *os.File
        err   error
    )

    if file, err = os.Open(fpath); err != nil {
        log.Fatalln(err)
    }
    defer file.Close()

    var (
        scanner = bufio.NewScanner(file)
        writer  = os.Stdout
        start   = time.Now()
    )

    for scanner.Scan() {
        fmt.Fprintln(writer, scanner.Text())
    }

    output.Stderrl("[Elapsed]", time.Since(start))
}

特におかしい部分はありません。ちゃんと動いてくれます。

んで、この処理にバカでかいファイル(1行が1024バイトで、1000000行)を食べさせた場合の実行速度が以下のようになったとします。

$ wc /tmp/try-golang-big.txt 
   1000000   16964980 1025000000 /tmp/try-golang-big.txt

$ ls -lh /tmp/try-golang-big.txt 
-rw-r--r-- 1 gitpod gitpod 978M Mar 29 07:54 /tmp/try-golang-big.txt

$ make run-nobuffering 
go run nobuffering/main.go > /dev/null
[Elapsed]            1.414633007s

約 1.4秒ほど。正直全然問題ないのですが、すこしパフォーマンスチューニングできる部分があります。

bufio.Scannerは、bufioパッケージの中の構造体なので、この子は既にバッファリングされています。

なので、調整する必要は特に無くて、今回の場合は見るべきは出力側です。

出力する側は、os.Stdout をそのまま使っています。大量データの場合、ここがボトルネックになります。

なので、出力側をバッファリングしましょう。 bufio.Writer を使います。

// 出力先を os.Stdout から *bufio.Writer に変更したサンプルです。
//
// REFERENCES
//   - https://stackoverflow.com/questions/64638136/performance-issues-while-reading-a-file-line-by-line-with-bufio-newscanner
//   - https://yourbasic.org/golang/temporary-file-directory/
//   - https://pkg.go.dev/os

package main

import (
    "bufio"
    "fmt"
    "log"
    "os"
    "time"

    "github.com/devlights/gomy/output"
)

func main() {
    var (
        fpath = "/tmp/try-golang-big.txt"
        file  *os.File
        err   error
    )

    if file, err = os.Open(fpath); err != nil {
        log.Fatalln(err)
    }
    defer file.Close()

    var (
        scanner = bufio.NewScanner(file)
        writer  = bufio.NewWriter(os.Stdout)
        start   = time.Now()
    )

    for scanner.Scan() {
        fmt.Fprintln(writer, scanner.Text())
    }

    output.Stderrl("[Elapsed]", time.Since(start))
}

最初のソースから、writer の部分を bufio.NewWriter にしただけです。

で、実行してみます。

$ make run-buffering1
go run buffering1/main.go > /dev/null
[Elapsed]            933.360868ms

少し速くなりました。もうちょいいけます。最後に

scanner.Text()

としている部分。ここで文字列を取得してから書き込んでいますが

このサンプルでは別段文字列にしてなんらかすることは無いのでバイト列でやり取りした方が速いです。

なので、scanner.Bytes() を使うようにしましょう。

// buffering1 のサンプルにプラスで
//   scanner.Text() ではなく scanner.Bytes() を
// 利用するように変更したサンプルです。
//
// REFERENCES
//   - https://stackoverflow.com/questions/64638136/performance-issues-while-reading-a-file-line-by-line-with-bufio-newscanner
//   - https://yourbasic.org/golang/temporary-file-directory/
//   - https://pkg.go.dev/os

package main

import (
   "bufio"
   "log"
   "os"
   "time"

   "github.com/devlights/gomy/output"
)

func main() {
    var (
        fpath = "/tmp/try-golang-big.txt"
        file  *os.File
        err   error
    )

    if file, err = os.Open(fpath); err != nil {
        log.Fatalln(err)
    }
    defer file.Close()

    var (
        scanner = bufio.NewScanner(file)
        writer  = bufio.NewWriter(os.Stdout)
        start   = time.Now()
    )

    for scanner.Scan() {
        writer.Write(scanner.Bytes())
        writer.WriteByte('\n')
    }

    output.Stderrl("[Elapsed]", time.Since(start))
}

出力ストリームに書き出す部分が少し変わりました。

実行してみましょう。

$ make run-buffering2
go run buffering2/main.go > /dev/null
[Elapsed]            601.419104ms

さらに少し速くなりました。

やっぱり、大きなデータを扱う場合はバッファリングは有効ですねー。

いつもバッファリングするべきとは思いません。小さなデータであれば素直に読み書きした方が見やすいですしね。

ケースバイケースで。

メモ代わりに、今回使ったMakefileを。

default: gen run-all

gen:
  tr -dc "A-Za-z 0-9" < /dev/urandom | fold -w1024 | head -n 1000000 > /tmp/try-golang-big.txt
  wc -l /tmp/try-golang-big.txt

run-all: run-nobuffering run-buffering1 run-buffering2

run-nobuffering:
  go run nobuffering/main.go > /dev/null

run-buffering1:
  go run buffering1/main.go > /dev/null

run-buffering2:
  go run buffering2/main.go > /dev/null

最後に実行結果です。

$ make
tr -dc "A-Za-z 0-9" < /dev/urandom | fold -w1024 | head -n 1000000 > /tmp/try-golang-big.txt
wc -l /tmp/try-golang-big.txt
1000000 /tmp/try-golang-big.txt
go run nobuffering/main.go > /dev/null
[Elapsed]            1.414633007s
go run buffering1/main.go > /dev/null
[Elapsed]            933.360868ms
go run buffering2/main.go > /dev/null
[Elapsed]            601.419104ms

今回のサンプルですが、以下にアップしてあります。

try-golang/examples/singleapp/fileio_performance_tips at master · devlights/try-golang · GitHub

参考情報

stackoverflow.com

devlights.hatenablog.com


過去の記事については、以下のページからご参照下さい。

サンプルコードは、以下の場所で公開しています。