regexp.Compileしたら正規表現がパフォーマンスいい感じに変換されていた

https://pkg.go.dev/regexp

Goで正規表現を扱うとき、先にCompileしておいて、それを対象にMatchしたりFindしたりが一般的だと思う。

package main

import (
	"fmt"
	"regexp"
)

func main() {
	// Compile the expression once, usually at init time.
	// Use raw strings to avoid having to quote the backslashes.
	var validID = regexp.MustCompile(`^[a-z]+\[[0-9]+\]$`)

	fmt.Println(validID.MatchString("adam[23]"))
	fmt.Println(validID.MatchString("eve[7]"))
	fmt.Println(validID.MatchString("Job[48]"))
	fmt.Println(validID.MatchString("snakey"))
}

で、このとき何らかの最適化がされているんだろうな〜とふと思って、内部で何をやっているか追ってみたら、regexp/syntaxのSimplify()というのを呼び出してた。

このあたり。 https://github.com/golang/go/blob/go1.24.4/src/regexp/regexp.go#L175

ドキュメントによると、簡略化してくれる、とだけある。

https://pkg.go.dev/regexp/syntax#Regexp.Simplify

Simplify returns a regexp equivalent to re but without counted repetitions and with various other simplifications, such as rewriting /(?:a+)+/ to /a+/. The resulting regexp will execute correctly but its string representation will not produce the same parse tree, because capturing parentheses may have been duplicated or removed. For example, the simplified form for /(x){1,2}/ is /(x)(x)?/ but both parentheses capture as $1. The returned regexp may share structure with or be the original.

で、syntax.Regexp には String() があって、その正規表現を返してくれる。つまりこういうことができる。

s, _ := syntax.Parse(`/(x){1,2}/`, syntax.Perl)
s.Simplify().String() // => /(x)(x)?/

ところで、Regexp::Assemble というのがあって。

404 Blog Not Found : perl - Regexp::Assembleのススメ

要は、qr/(?:ab+c|ab+-|a\w\d+|a\d+)/と書くよりも、qr/(?:\w?\d+|b+[-c])/と書いた方が高速な正規表現になるので、それを自動化しようというものです。

これブログ本文がタイポしてて a(?:\w?\d+|b+[-c]) が正しいと思う(すぐ上の出力にそうでてる)

というので、Goで試すと結果が違う。

s, _ := syntax.Parse(`/(?:ab+c|ab+-|a\w\d+|a\d+)/`, syntax.Perl)
s.Simplify().String() // => /a(?:b+c|b+-|[0-9A-Z_a-z][0-9]+|[0-9]+)/

s, _ = syntax.Parse(`/a(?:\w?\d+|b+[-c])/`, syntax.Perl)
s.Simplify().String() // => a(?:[0-9A-Z_a-z]?[0-9]+|b+[\-c])

簡単にベンチマークをしてみる。あえてJavascriptでやってみるとこうなる。

const NUM_LOOPS = 5 * 1000 * 1000;
function bench(name, s) {
  const start = performance.now();

  const test = () => {
    return (
      "abc".match(s) !== null &&
      "abbbbc".match(s) !== null &&
      "abbbbc".match(s) !== null &&
      "abbb-".match(s) !== null &&
      "a0123456789".match(s) !== null &&
      "a0A1a2abet34531DSxxaAGSAaxcRh56asd7ay81asfhuaaSD999876543".match(s) !== null &&
      "a1234543456789098765432456788765".match(s) !== null
    );
  };

  for (let i = 0; i < NUM_LOOPS; i++) {
    if (!test()) {
      console.log(`${name}: fail`);
      return;
    }
  }

  console.log(`${name}: success. ${performance.now() - start}`);
}

bench("Original", /(?:ab+c|ab+-|a\w\d+|a\d+)/);
bench("Regexp::Assemble", /a(?:\w?\d+|b+[-c])/);
bench("Simplify(Original)", /a(?:b+c|b+-|[0-9A-Z_a-z][0-9]+|[0-9]+)/);
bench("Simplify(Regexp::Assemble)", /a(?:[0-9A-Z_a-z]?[0-9]+|b+[\-c])/);
 "Original: success. 919.8000001907349"
 "Regexp::Assemble: success. 931.8000001907349"
 "Simplify(Original): success. 951.4000000953674"
 "Simplify(Regexp::Assemble): success. 934.5999999046326"

Goだと例えば毎回コンパイルするとこう。

func BenchmarkRegexp1(b *testing.B) {
	test := func(s string) bool {
		return regexp.MustCompile(s).MatchString("abc") &&
			regexp.MustCompile(s).MatchString("abbbbc") &&
			regexp.MustCompile(s).MatchString("abbbbc") &&
			regexp.MustCompile(s).MatchString("abbb-") &&
			regexp.MustCompile(s).MatchString("a0123456789") &&
			regexp.MustCompile(s).MatchString("a0A1a2abet34531DSxxaAGSAaxcRh56asd7ay81asfhuaaSD999876543") &&
			regexp.MustCompile(s).MatchString("a1234543456789098765432456788765")
	}

	b.Run("Original", func(b *testing.B) {
		for range b.N {
			if !test(`(?:ab+c|ab+-|a\w\d+|a\d+)`) {
				b.Fail()
			}
		}
	})
	b.Run("Regexp::Assemble", func(b *testing.B) {
		for range b.N {
			if !test(`a(?:\w?\d+|b+[-c])`) {
				b.Fail()
			}
		}
	})
	b.Run("Simplify(Original)", func(b *testing.B) {
		for range b.N {
			if !test(`a(?:b+c|b+-|[0-9A-Z_a-z][0-9]+|[0-9]+)`) {
				b.Fail()
			}
		}
	})
	b.Run("Simplify(Regexp::Assemble)", func(b *testing.B) {
		for range b.N {
			if !test(`a(?:[0-9A-Z_a-z]?[0-9]+|b+[\-c])`) {
				b.Fail()
			}
		}
	})
}
BenchmarkRegexp1
BenchmarkRegexp1/Original
BenchmarkRegexp1/Original-10         	   55278	     22182 ns/op
BenchmarkRegexp1/Regexp::Assemble
BenchmarkRegexp1/Regexp::Assemble-10 	   82652	     14493 ns/op
BenchmarkRegexp1/Simplify(Original)
BenchmarkRegexp1/Simplify(Original)-10         	   65221	     19316 ns/op
BenchmarkRegexp1/Simplify(Regexp::Assemble)
BenchmarkRegexp1/Simplify(Regexp::Assemble)-10 	   82207	     14639 ns/op
PASS

Compileはやっぱり先にやってほしいよね。と先にやってからMatchStringするようにもしてみる。

func BenchmarkRegexp2(b *testing.B) {
	test := func(r *regexp.Regexp) bool {
		return r.MatchString("abc") &&
			r.MatchString("abbbbc") &&
			r.MatchString("abbbbc") &&
			r.MatchString("abbb-") &&
			r.MatchString("a0123456789") &&
			r.MatchString("a0A1a2abet34531DSxxaAGSAaxcRh56asd7ay81asfhuaaSD999876543") &&
			r.MatchString("a1234543456789098765432456788765")
	}

	b.Run("Original", func(b *testing.B) {
		r := regexp.MustCompile(`(?:ab+c|ab+-|a\w\d+|a\d+)`)
		for range b.N {
			if !test(r) {
				b.Fail()
			}
		}
	})
	b.Run("Regexp::Assemble", func(b *testing.B) {
		r := regexp.MustCompile(`a(?:\w?\d+|b+[-c])`)
		for range b.N {
			if !test(r) {
				b.Fail()
			}
		}
	})
	b.Run("Simplify(Original)", func(b *testing.B) {
		r := regexp.MustCompile(`a(?:b+c|b+-|[0-9A-Z_a-z][0-9]+|[0-9]+)`)
		for range b.N {
			if !test(r) {
				b.Fail()
			}
		}
	})
	b.Run("Simplify(Regexp::Assemble)", func(b *testing.B) {
		r := regexp.MustCompile(`a(?:[0-9A-Z_a-z]?[0-9]+|b+[\-c])`)
		for range b.N {
			if !test(r) {
				b.Fail()
			}
		}
	})
}
BenchmarkRegexp2
BenchmarkRegexp2/Original
BenchmarkRegexp2/Original-10         	 1226598	       893.7 ns/op
BenchmarkRegexp2/Regexp::Assemble
BenchmarkRegexp2/Regexp::Assemble-10 	 1288764	       921.0 ns/op
BenchmarkRegexp2/Simplify(Original)
BenchmarkRegexp2/Simplify(Original)-10         	 1346878	       911.8 ns/op
BenchmarkRegexp2/Simplify(Regexp::Assemble)
BenchmarkRegexp2/Simplify(Regexp::Assemble)-10 	 1305112	       918.2 ns/op
PASS

うーん….


じゃあどんな書き方をするのが一番いいだろうか??という気持ちになるが、それはある程度最適化する手段はあるものの、究極的には正規表現を処理するエンジンの実装によるだろうな〜と思った。

少なくとも、Goのコードの中では、Compileしたら自動的にSimplify()してくれるので、わざわざSimplify()したパターンを事前に準備する必要はないだろうし、Regexp::Assemble的なこともいらないと思う。素直で愚直な正規表現を書いて、Simplify()してもらうのがGo的には一番パフォーマンスがでるんじゃないだろうか。

サイト案内

運営してるひと: @sters9

最近は Go, Ruby, Rails, Kubernetes, GCP, Datadog あたりをしていますがもっといろいろやりたい!

プロフィール

開発環境の紹介

プライバシーポリシー

tools.gomiba.co

サイト内検索

アーカイブ

2025/06 (1) 2025/05 (1) 2025/04 (3) 2025/03 (1) 2025/02 (3) 2025/01 (4)

2024/12 (2) 2024/09 (3) 2024/07 (1) 2024/06 (3) 2024/05 (1) 2024/04 (7) 2024/03 (4) 2024/01 (3)

2023/12 (1) 2023/11 (3) 2023/10 (1) 2023/09 (1) 2023/08 (2) 2023/05 (4) 2023/04 (4) 2023/03 (4) 2023/02 (2) 2023/01 (1)

2022/12 (2) 2022/11 (4) 2022/10 (3) 2022/09 (2) 2022/08 (4) 2022/07 (5) 2022/06 (4) 2022/05 (9) 2022/04 (8) 2022/03 (10) 2022/02 (21) 2022/01 (8)

2021/12 (11) 2021/11 (1) 2021/10 (4) 2021/09 (2) 2021/08 (1) 2021/07 (2) 2021/06 (5) 2021/05 (10) 2021/04 (1) 2021/03 (8) 2021/02 (12) 2021/01 (8)

2020/05 (2) 2020/04 (2) 2020/02 (2) 2020/01 (1)

2019/12 (3) 2019/11 (2) 2019/10 (5) 2019/09 (3) 2019/07 (6) 2019/06 (4) 2019/04 (3) 2019/01 (2)

2018/12 (6) 2018/10 (4) 2018/09 (6) 2018/08 (7) 2018/07 (16) 2018/06 (7) 2018/05 (7) 2018/04 (5) 2018/03 (3) 2018/02 (10) 2018/01 (6)

2017/12 (8) 2017/11 (6) 2017/10 (10) 2017/09 (12) 2017/08 (12) 2017/07 (3) 2017/06 (1) 2017/01 (4)

2016/12 (5) 2016/10 (3) 2016/09 (1) 2016/07 (2) 2016/06 (1) 2016/04 (1) 2016/02 (1) 2016/01 (2)

2015/12 (1) 2015/10 (1) 2015/09 (3) 2015/06 (1) 2015/01 (1)

2014/08 (2) 2014/07 (3) 2014/05 (1) 2014/01 (7)

2013/12 (2) 2013/11 (4) 2013/10 (1) 2013/09 (1) 2013/08 (3) 2013/07 (4) 2013/06 (5) 2013/05 (2) 2013/04 (7) 2013/03 (1)