📚

【Go】ファイルへの書き込みをバッファリングに修正してみた

に公開2

概要

DBからレコードを取得してテキストファイルに出力する処理の中でfmt.Fprintfを使用している箇所をbufio.Writer のバッファリングを利用するように修正してみた。

どのくらいパフォーマンスが向上したのかベンチマークテストも行ってみた。

環境

  • MacBook Air M1 メモリ16GB
  • go 1.23.0、PostgreSQL 14.5

計測方法

  • Todosテーブルから10,000件のレコードを取得しテキストファイルに出力する

サンプルコード

fmt.Fprintf使用

func BenchmarkFprintf(b *testing.B) {
	db, _ := dbConn()
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		FileOutPutTodosWithFprintf(db, "file_output.txt")
		os.Remove("file_output.txt")
	}
}

func FileOutPutTodosWithFprintf(db *gorm.DB, fileName string) error {
	file, err := os.Create(fileName)
	if err != nil {
		return err
	}
	defer file.Close()

	var todos []Todo
	result := db.Find(&todos)
	if result.Error != nil {
		return result.Error
	}

	for _, todo := range todos {
		fields := []string{
			fmt.Sprintf("ID: %v", todo.ID),
			fmt.Sprintf("Title: %v", todo.Title),
			fmt.Sprintf("Note: %v", todo.Note),
		}
		_, err := fmt.Fprintf(file, "{%s},\n", strings.Join(fields, ", "))
		if err != nil {
			return err
		}
	}

	return nil
}

bufio.Writer使用

func BenchmarkBufio(b *testing.B) {
	db, _ := dbConn()
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		FileOutPutTodosWithBufio(db, "file_output.txt")
		os.Remove("file_output.txt")
	}
}

func FileOutPutTodosWithBufio(db *gorm.DB, fileName string) error {
	file, err := os.Create(fileName)
	if err != nil {
		return err
	}
	defer file.Close()

	writer := bufio.NewWriter(file)
	defer writer.Flush()

	var todos []Todo
	result := db.Find(&todos)
	if result.Error != nil {
		return result.Error
	}

	for _, todo := range todos {
		fields := []string{
			fmt.Sprintf("ID: %v", todo.ID),
			fmt.Sprintf("Title: %v", todo.Title),
			fmt.Sprintf("Note: %v", todo.Note),
		}
		line := fmt.Sprintf("{%s},\n", strings.Join(fields, ", "))
		_, err = writer.Write([]byte(line))
		if err != nil {
			return err
		}
	}

	return nil
}

計測結果

=== RUN   BenchmarkFprintf
BenchmarkFprintf
BenchmarkFprintf-8  31  33787055 ns/op  11289062 B/op  249601 allocs/op

=== RUN   BenchmarkBufio
BenchmarkBufio
BenchmarkBufio-8    61  19502351 ns/op  13212685 B/op  269613 allocs/op

fmt.Fprintf使用(BenchmarkFprintf)

  • 実行回数: 31回
  • 平均実行時間: 33,787,055 ns/op(約33.8ms/1回)
  • メモリ使用量: 11,289,062 B/op(約11.2MB/1回)
  • メモリアロケーション回数: 249,601回

bufio.Writer使用(BenchmarkBufio)

  • 実行回数: 61回
  • 平均実行時間: 19,502,351 ns/op(19.5ms/1回)
  • メモリ使用量: 13,212,685 B/op(約13.2MB/1回)
  • メモリアロケーション回数: 269,613回

比較

fmt.Fprintf使用 bufio.Writer使用 比較
実行回数 31回 61回 約1.9倍 増
平均実行時間 33,787,055 ns/op(約33.8ms/1回) 19,502,351 ns/op(19.5ms/1回) 約42.2% 減
メモリ使用量 11,289,062 B/op(約11.2MB/1回) 13,212,685 B/op(約13.2MB/1回) 約17.0% 増
メモリアロケーション回数 249,601回 269,613回 約8.0% 増

パフォーマンスの差の要因

I/O回数がfmt.Fprintf使用にくらべてbufio.Writer使用の方が少ないことが要因か

  • fmt.Fprintf使用のI/O回数 → 10,000回(ループの回数分)
  • bufio.Writer使用のI/O回数 → 212回(Flushの回数を計測した結果)

結論

  • 今回の計測方法ではbufio.Writerのバッファリング処理の方が実行速度いる結果となった
    • メモリに関してはfmt.Fprintfを使用したほうが若干優れている結果となった
      • バッファリングの場合は内部バッファの追加メモリ確保により、メモリ使用量とアロケーション数が増加した?
  • 処理速度を気にする場面ではバッファリングを使用していきたい
GitHubで編集を提案

Discussion

ngicksngicks

*bufio.Writer自体をsync.Poolでプーリングすればもっとallocationが減りそうですね。
fmt.*printfは内部的にprinterをプーリングしているのでその分でallocationが増えたのかなと思います。

+var bufWriterPool = &sync.Pool{
+	New: func() any {
+		return bufio.NewWriter(nil)
+	},
+}
+
+func getBufWriter(w io.Writer) *bufio.Writer {
+	bufw := bufWriterPool.Get().(*bufio.Writer)
+	bufw.Reset(w)
+	return bufw
+}
+
+func putBufWriter(bufw *bufio.Writer) {
+	bufw.Reset(nil)
+	bufWriterPool.Put(bufw)
+}
+
- func fileOutPutTodosWithStreamBench(db *gorm.DB, fileName string) error {
+ func fileOutPutTodosWithStreamBench(db *gorm.DB, fileName string) (err error) {
	file, err := os.Create(fileName)
	if err != nil {
		return err
	}
	defer file.Close()

-	writer := bufio.NewWriter(file)
-	defer writer.Flush()
+	writer := getBufWriter(file)
+	defer putBufWriter(writer)
+	defer func() {
+		flushErr := writer.Flush()
+		if err == nil {
+			err = flushErr
+		}
+	}()

	var todos []Todo
	result := db.Find(&todos)
	if result.Error != nil {
		return result.Error
	}

	for _, todo := range todos {
		fields := []string{
			fmt.Sprintf("ID: %v", todo.ID),
			fmt.Sprintf("Title: %v", todo.Title),
			fmt.Sprintf("Note: %v", todo.Note),
		}
		line := fmt.Sprintf("{%s},\n", strings.Join(fields, ", "))
		_, err = writer.Write([]byte(line))
		if err != nil {
			return err
		}
	}

	return nil
}

さらにfmt.Sprintf部分も減らせますね。

	for rows.Next() {
		var todo Todo
-		db.ScanRows(rows, &todo)
-
-		fields := []string{
-			fmt.Sprintf("ID: %v", todo.ID),
-			fmt.Sprintf("Title: %v", todo.Title),
-			fmt.Sprintf("Note: %v", todo.Note),
-		}
-
-		line := fmt.Sprintf("{%s},\n", strings.Join(fields, ", "))
-		_, err := writer.WriteString(line)
+		err = db.ScanRows(rows, &todo)
+		if err != nil {
+			return err
+		}
+
+		fields := [][2]string{
+			{"ID", strconv.FormatInt(int64(todo.ID), 10)},
+			{"Title", todo.Title},
+			{"Note", todo.Note},
+		}
+		_ = writer.WriteByte('{')
+		for i, f := range fields {
+			if i > 0 {
+				_, _ = writer.WriteString(", ")
+			}
+			_, _ = writer.WriteString(f[0])
+			_, _ = writer.WriteString(": ")
+			_, _ = writer.WriteString(f[1])
+		}
+		_, err = writer.WriteString("},\n")
+		if err != nil {
+			return err
+		}
	}

上記のスニペットは一度も実行していませんが、参考までに。

keiai0keiai0

@ngicks さん
ありがとうございます!!
大変参考になりますmm

*bufio.Writer自体をsync.Poolでプーリングすればもっとallocationが減りそうですね。

sync.Poolなど調べてみて、ご提案いただいたコードを試してみようと思います!