Django 3.1 MySQL db_flush() の高速化とTransactionTestCase利用時の注意点
追記: 翔泳社さんからDjangoの書籍を出版するので、ぜひ読んでみてください。
DjangoのMySQL DatabaseOperations Backendのとある処理を最適化するためのpatchを書いていて、それがマージされたのですが、注意点があるため記事にしておこうと思います。 全部読むのが面倒な方向けに結論だけ先に書いておきます。
- MySQLにおいて、TransactionTestCaseのteardown処理が高速になりました。
- またほとんどのユーザーにはそれほど重要ではありませんが、
sqlflush
コマンドも効率的なクエリを生成し、flush
コマンドも高速になります。
- またほとんどのユーザーにはそれほど重要ではありませんが、
- TransactionTestCaseを使ったテストで
AUTO INCREMENT
フィールドの値(デフォルトの主キーなど)に依存しているテストは、そのままだとMySQLではFAILするようになります。
テスト時間が長く困っている方は3.1以降で改善されるかもしれません。
TransactionTestCaseのteardown処理について
厳密には flush
management comandの高速化を行ったのですが、どうしてTransactionTestCaseが速くなるのかを解説します。
これにはTransactionTestCaseがtear down時に何をしているのかを理解する必要があります。
Djangoでテストを書いているという方はご存知のように、通常の TestCase
では、テスト開始時にトランザクションを開始し、終了時にはロールバックすることで、テストメソッド内のDB操作を取り消します。つまり各テストケースで行った操作はロールバックにより切り戻されるため、他のテストケースの実行に影響を与えることはありません。
一方でそれだと困るケースも当然あり、そういうケースでは TransactionTestCase
を使用します。
TrasactionTestCase
は、各テストケースごとに全てのテーブルを初期化した状態にしておかないといけません。
どうやっているかというと、 flush
management commandを call_command()
関数で発行しています。
この関数は、内部で各DatabaseOperations Backendの sql_flush()
メソッドを呼び出し、そのメソッドが返すSQLを実行します。
flush
management commandを普通に利用する人は、ここが少し速くなったところでそれほど嬉しくないと思いますが、何度もteardownで呼び出されるTransactionTestCase
においては速度が重要になります。
sql_flush()
の高速化
MySQL DatabaseOperations backendの sql_flush()
メソッドは、全テーブルに対して TRUNCATE
クエリを返す実装になっていました。
TRUNCATE
クエリはドキュメントにも書いてあるように、 DELETE
クエリや、 DROP TABLE
&& CREATE TABLE
クエリに似ています。
TRUNCATE TABLE empties a table completely. It requires the DROP privilege. Logically, TRUNCATE TABLE is similar to a DELETE statement that deletes all rows, or a sequence of DROP TABLE and CREATE TABLE statements. https://dev.mysql.com/doc/refman/8.0/en/truncate-table.html
ただこのクエリは比較的時間がかかります。厳密にはテーブルのサイズが小さい場合に、DELETE
クエリに比べ時間がかかります。
そこで DELETE
クエリを使いたいのですが、 DELETE
クエリで全ての情報が消えるわけではありません。
問題になるのは AUTO INCREMENT
フィールドのカウンターの値です。
3つのレコードが登録され、その全てをDELETEクエリにより削除した場合、次に作成するレコードの AUTO INCREMENT
フィールドは4から開始します。
そのため別途 ALTER TABLE tablename AUTO_INCREMENT = 1
などを発行してリセットする必要があります。
最初に書いたpatchではそういう処理をしていたのですが、core contributorsやmaintainerからのレビューを通して、カウンター値はリセットしないことになりました。
カウンター値がリセットされなくなることは破壊的変更のように思えますが、実はカウンター値がリセットされるかどうかは未定義動作だったようです。
Djangoのモデルの主キーは、デフォルトだとAUTO INCREMENT
なINTEGERが利用されますが、この主キー値の値などをテストケースでチェックしている場合には、後述するオプションをつけていないとFailします。
TransactionTestCase
の reset_sequences
オプション
先程の問題に対処するためには、ALTER TABLE tablename AUTO_INCREMENT = 1
を合わせて発行する必要があると説明しました。
それを有効にするのが TransactionTestCase
の reset_sequences
オプションです。
詳細は Advanced features of TransactionTestCase に書かれています。
Django 3.0まではMySQL利用時にこれをセットしても変わらなかったのですが、3.1からは必要に応じて reset_sequences=True
を指定してください。
class TestsThatDependsOnPrimaryKeySequences(TransactionTestCase): reset_sequences = True def test_animal_pk(self): ...
細かく確認してないのですが、PostgreSQLのDatabase Operations backendのコードには、このreset_sequencesの処理が実装されていたので、PostgreSQLを使ってTransactionTestCase走らせていた方には常識だったのかもしれません。
ベンチマーク
ベンチマークのコードはこちらです。
GitHub - c-bata/django-fast-mysql-flush: for ticket #31275
number of records on each table | before | after |
---|---|---|
10 | 3.302 sec (+/- 0.076) | 0.517 sec (+/- 0.019) |
100 | 3.323 sec (+/- 0.047) | 0.575 sec (+/- 0.025) |
1000 | 3.577 sec (+/- 0.106) | 1.046 sec (+/- 0.029) |
余談 ( information_schema.tables
の利用)
今回書いたpatchは、もともとヒューリスティックに1000行以下ならDELETE
クエリを発行するように実装していました。
行数が多い場合には性能の改善がなく、むしろ遅くなる可能性もあるからです。
全テーブルに対して SELECT COUNT(*)
で行数を調べていると余計に時間がかかる可能性があるため、 information_schema.tables
のtable_rows
から行数を取り出して判断しました。
この値はおおよその値が返ってくるだけですが(MyISAMを除く)、今回のようにざっくり1000行以上あるかどうかを知りたいときには十分です。
TABLE_ROWS
The number of rows. Some storage engines, such as MyISAM, store the exact count. For other storage engines, such as InnoDB, this value is an approximation, and may vary from the actual value by as much as 40% to 50%. In such cases, use SELECT COUNT(*) to obtain an accurate count.
TABLE_ROWS is NULL for INFORMATION_SCHEMA tables.
For InnoDB tables, the row count is only a rough estimate used in SQL optimization. (This is also true if the InnoDB table is partitioned.) https://dev.mysql.com/doc/refman/8.0/en/tables-table.html
ただ最終的にこの方針はやめることになりました。
自分が用意したベンチマークでは2倍程度高速でしたが、 DELETE
文と ALTER TABLE tablename AUTO INCRMENT = 1
の2つのSQLを発行しているためcore contributorsの方が試したベンチマーク問題では遅くなったりもしたようです。
詳しく見てみようかとも思ったのですが、ヒューリスティックを入れるのはあまり筋がよくないのと、たかだか2倍程度の改善だったのでまぁいいかなと思い今の実装に落ち着きました。
ちなみに information_schema.tables
から auto_increment
を取り出して、それが1より大きい場合のみ TRUNCATE
を呼ぶという実装も試してみたのですが、INSERTをしても AUTO_INCREMENT
の値が更新されず1のままにいることが頻繁にありテストが落ちるため諦めました。ドキュメントを読んでもapproximationとは書かれていないので理由がよくわからないのですが、もし知ってる方いたら教えて下さい。
AUTO_INCREMENT: The next AUTO_INCREMENT value. https://dev.mysql.com/doc/refman/5.7/en/tables-table.html
自分があとから思い出すためのメモでもあったので、雑な記事でしたがこれで終わり。
Github ActionsでクロスコンパイルしてGithub Releaseにアップロードする
追記: GoReleaserのbabarotさんの記事があったので紹介。GoReleaser使ったほうが手軽でよさそう。 Go で書いた CLI ツールのリリースは GoReleaser と GitHub Actions で個人的には決まり | tellme.tokyo
追記: GoReleaserのプロジェクトがGithub Actionを公開したようなのでこちら使ってみるといいかもしれません。 https://github.com/goreleaser/goreleaser-action
この前kube-promptのリリースでミスをしてしまいissueが立て続けに2件上がったことがあったのですが、Github Actionsが自分のリポジトリで使えるようになったのでリリースを自動化することにしました。
触ってみてわかったのですが、↓の記事を書いたときとは多くの変更がありました。workflowの記述フォーマットがyamlに変更されていたり、Azure Pipelinesを裏側で使うようになったことでSupported virtual environmentsにmacOSやWindowsが増えたり、その影響でCustom actionはDockerコンテナベースではなくJavaScriptアプリケーションとして記述できるようになったりしています。JavascriptでCustom actionを記述するのはまた今度にして、とりあえず今回はyaml形式のworkflowを使っていきます。
クロスコンパイルとGithub releasesへのアップロード
kube-promptではこんな感じでいくつかバイナリをGithub Releaseに含めています。今回は tagが作成されたタイミングでフックしてこれらのbinaryをcross compile, zipで圧縮してGithub Release画面を作成します。
まずworkflowのevent trigger指定します。 今回からcronのように定期実行もできるようになっていますが、基本的には GithubのWebhook event を起動トリガーにすることがほとんどかと思います。git tagのcreateイベントをトリガーにしたいので次のようになります 1 。
on: create: tags: - v*.*.*
次にjobs propertyより実際にGithub Actionsで行いたい操作を記述します。 そんなに長くなくて↓のような感じです。
jobs: release: # job_id name: Build runs-on: ubuntu-latest steps: - name: Set up Go 1.12 uses: actions/setup-go@v1 with: version: 1.12 id: go - name: Check out code into the Go module directory uses: actions/checkout@master - name: Build env: GO111MODULE: on GOPATH: /home/runner/work/ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | export CREATE_EVENT_REF_TYPE=$(jq --raw-output .ref_type "$GITHUB_EVENT_PATH") export TAGNAME=$(jq --raw-output .ref "$GITHUB_EVENT_PATH") if [ "$CREATE_EVENT_REF_TYPE" != "tag" ]; then echo "ref_type is not a tag: ${CREATE_EVENT_REF_TYPE}" && exit 78; fi make cross go get -u github.com/tcnksm/ghr $GOPATH/bin/ghr -n ${TAGNAME} -b "ʕ◔ϖ◔ʔ Release ${TAGNAME}" -draft ${TAGNAME} pkg/
jobs.<job_id>.runs-on
に実行環境を指定することができて、今回からmacOSやWindowsが追加されました。kube-promptはCGOの依存とかもなく普通にクロスコンパイルできるので、一番速度や動作が安定してそうなubuntuにしています。他のCIサービスと同様に、matrixも組めるのでテストとか必要に応じてやっておくといいかと思います。- GITHUB_TOKENは以前までだと自分でsecretsに登録する必要がありましたが、今回からはその必要も無いようです。yaml内で
${{secrets.GITHUB_TOKEN}}
とするだけで取り出せます。- どこまでが古いドキュメントにしか書かれてなくて、どこまでが新しいドキュメントに書かれてるのか分からずこれに気づくの少し時間がかかってしまいました。
$GITHUB_EVENT_PATH
にはWebhook eventのJSONファイルがそのまま入っています。今回はcreate
イベントにフックしているので、 こちら にあるように、ref_type
フィールドからtag名、ref
フィールドからrevision hashが取り出せます。make cross
の処理はこんな感じです https://github.com/c-bata/kube-prompt/blob/4e31373f5a10443746b4ab93d584ea0ec17d4e61/Makefile#L31-L43- 最後にghrをgo getしてきてアップロードします。Githubのtokenは
GITHUB_TOKEN
環境変数を見てくれているので特にコマンド実行時には指定する必要がありません。 - ~ちなみに
actions/setup-go
は$GOPATH
が空で$GOPATH/bin
にもパスが通っていないようなので、明示的に指定しました。直したほうがいい気がしますが、JavaScriptベースのCustom actionとして定義されているらしく、まだ自分も調べてないところだったので今度必要になって調べることがあればついでに提案してみたいとおもいます。~uses
で何もDockerイメージが指定されていないstepは、runs-on
で指定したホスト上でそのまま動いているかもです。setup-go
はそのホスト上にGoの環境を構築してくれているということな気がします。ただ環境変数とかは引き継げないのかな?- https://github.com/actions/setup-go
全体像はこんな感じです。試しに v0.0.1 みたいなタグをpushして無事にバイナリ付きでGithub Releaseが作成されることは確認しました。
https://github.com/c-bata/kube-prompt/blob/master/.github/workflows/release.yml
おまけ: golangci-lintとgo testの実行
Goptunaという最近開発してるプロジェクトはまだCIを導入していなかったので、Lintとテストを実行できるようにしました。 他のプロジェクトでもほとんどそのまま使える点も多いと思うのでよければ参考にしてみてください。
name: Run tests and lint checks on: pull_request: branches: - master jobs: lint: name: Lint checking on Ubuntu runs-on: ubuntu-latest steps: - name: Set up Go 1.12 uses: actions/setup-go@v1 with: version: 1.12 id: go - name: Check out code into the Go module directory uses: actions/checkout@master - name: Running golangci-lint env: GO111MODULE: on GOPATH: /home/runner/work/ run: | go get -u github.com/golangci/golangci-lint/cmd/golangci-lint GOCILINT=${GOPATH}/bin/golangci-lint make lint test: name: Test on ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, windows-latest, macOS-latest] steps: - name: Set up Go 1.12 uses: actions/setup-go@v1 with: version: 1.12 id: go - name: Check out code into the Go module directory uses: actions/checkout@master - name: Running go tests run: make test
https://github.com/c-bata/goptuna/blob/master/.github/workflows/run-tests.yml
追記:
Go関係ないですが、ついでにSphinxのドキュメントをビルドしてGCSにアップロードするscript書いたのでgcloudとかgsutil使いたい方とか参考にしたい方はどうぞ。本当は https://github.com/actions/gcloud を使おうと思ったのですが柔軟性なくて今回の用途には使えなかったのでやめました。
Github Actions Workflow to build your sphinx documentation and upload it to Google Cloud Storage. https://gist.github.com/c-bata/ed5e7b7f8015502ee5092a3e77937c99
おわりに
travis CIを長く使ってきましたが、Windowsの動作が不安定だったり苦労している点が結構ありました。 ActionsはAzure Pipelinesがバックエンドにあることもあり、立ち上がりがかなり速い印象です。 forkしたらそのままActionsが使えるというメリットもあるので、OSSでは積極的に使っていこうと思います。
LLVMのauto-vectorizationとc2goasmによるGo Plan9 Assemblyの生成によるSIMD最適化
InfluxDBの開発チームはApache Arrowの技術に注目していて、ArrowのGo実装の開発にも積極的に参加しています。Stuart Carine (InfluxDBの開発チームメンバー)がApache ArrowのGo実装に取り入れたc2goasmとLLVMを使った最適化が以前話題になりました。
c2goasm はClangで生成したアセンブリをGo Plan9 Assemblyに変換できる汎用的なコマンドラインツールです。しかしその活用事例はほとんどが、Goコンパイラーが行っていないSSEやAVX命令を用いたSIMD最適化です。この記事ではAVX2によるSIMD演算を実現するための c2goasm の使い方を整理します。最終的にどれくらい速くなるかでいうとfloat64のsum演算は10x以上高速化しました。
$ go test -bench . goos: darwin goarch: amd64 pkg: github.com/c-bata/sample-c2goasm BenchmarkSumFloat64_256-4 5000000 282 ns/op BenchmarkSumFloat64_1024-4 1000000 1234 ns/op BenchmarkSumFloat64_8192-4 200000 10021 ns/op BenchmarkSumFloat64_AVX2_256-4 50000000 23.5 ns/op BenchmarkSumFloat64_AVX2_1024-4 20000000 95.9 ns/op BenchmarkSumFloat64_AVX2_8192-4 2000000 904 ns/op PASS ok github.com/c-bata/sample-c2goasm 10.911s
c2goasm
- Github: https://github.com/minio/c2goasm
- 作者minioによる解説: https://blog.minio.io/c2goasm-c-to-go-assembly-bb723d2f777f
c2goasm の使い方は次のとおりです。
ツールとしての作り込みが少し雑な印象はありハマりどころもありますが、この3つのステップを踏めばSIMDを使って最適化したC/C++のコードを少ないオーバーヘッドでGoから呼び出せます。
CgoによるCの関数呼び出し
Goにおいてデファクトスタンダードとなっている cgo は性能面において優れたソリューションではありません 1 。c2goasmで生成されたGo Plan9 Assemblyのサブルーチン呼び出しは、他のGoの関数呼び出しと同程度の効率で実行できるようです。
GithubリポジトリのREADMEにはcgoとの性能比較が紹介されています。 https://github.com/minio/c2goasm#benchmark-against-cgo
Cのコードの記述
float64のarrayに入った値の合計値を計算する関数を用意します。 SIMDによる演算で合計値の計算をするのは少しアルゴリズム的な工夫が必要です。
#include <immintrin.h> void sum_float64_avx_intrinsics(double buf[], size_t len, double *res) { __m256d acc = _mm256_set1_pd(0); for (int i = 0; i < len; i += 4) { __m256d v = _mm256_load_pd(&buf[i]); acc = _mm256_add_pd(acc, v); } acc = _mm256_hadd_pd(acc, acc); // a[0] = a[0] + a[1], a[2] = a[2] + a[3] *res = _mm256_cvtsd_f64(acc) + _mm_cvtsd_f64(_mm256_extractf128_pd(acc, 1)); }
このようにintrinsicsを使って自分でかくこともできますが、実装が少し大変です。 LLVMのAuto-Vectorization にこの辺の最適化は任せられるなら楽ができます。
void sum_float64(double buf[], int len, double *res) { double acc = 0.0; for(int i = 0; i < len; i++) { acc += buf[i]; } *res = acc; }
これをClangでコンパイルしてみます。
$ clang -S -mavx2 -masm=intel -mno-red-zone -mstackrealign -mllvm -inline-threshold=1000 -fno-asynchronous-unwind-tables -fno-exceptions -fno-rtti -c sum_avx_intrinsics.c
オプションはこの辺を参考にしてください。
実行すると ~.s
とアセンブリファイルが生成されるので中を見るとこのような感じです。
.section __TEXT,__text,regular,pure_instructions .build_version macos, 10, 14 .intel_syntax noprefix .globl _sum_float64 ## -- Begin function sum_float64 .p2align 4, 0x90 _sum_float64: ## @sum_float64 ## %bb.0: ... vxorps xmm0, xmm0, xmm0 mov qword ptr [rsp + 40], rdi mov dword ptr [rsp + 36], esi mov qword ptr [rsp + 24], rdx vmovsd qword ptr [rsp + 16], xmm0 ...
vmovsdといった命令やレジスタ xmm0 を利用していることからLLVMがSIMD命令を活用していることがわかります。サブルーチン名 _sum_float64
はGoの関数定義で必要なので覚えておきます。
※ xmm0レジスタは128 bitsしかないレジスタのため、64 bitsを消費するdoubleの演算は2要素しか一度に演算できません。おそらくSSEの命令を利用しています。ここで ymm0 など128 bitsのレジスタを使い AVX2の命令を呼び出してほしいのですが、Clangのバージョンや最適化オプションによりうまくいったりイカなかったりするようです。詳細はGithubをご覧ください。
Goの関数定義
package c2goasm_sample import "unsafe" //go:noescape func __sum_float64(buf, len, res unsafe.Pointer) func SumFloat64Avx2(a []float64) float64 { var ( p1 = unsafe.Pointer(&a[0]) p2 = unsafe.Pointer(uintptr(len(a))) res float64 ) __sum_float64(p1, p2, unsafe.Pointer(&res)) return res }
シグネチャはアセンブリ内のサブルーチン名に _
を追加したものです。今回のようにもしサブルーチン名が _
から始まっていたら、 __
で始まることに注意してください。
https://github.com/minio/c2goasm/blob/0325a40cfd1fc6a5097e69eaf0292990eb6cee6a/arguments.go#L85
c2goasm実行
$ go get -u github.com/minio/asm2plan9s $ go get -u github.com/minio/c2goasm $ go get -u github.com/klauspost/asmfmt/cmd/asmfmt $ c2goasm -a -f _lib/sum_avx_intrinsics.s sum_avx.s
これでGoアセンブリが生成されます。第2引数が出力ファイルですが、Goのファイル名を sum_avx.go
としたときは拡張子を .s
に変えただけの sum_avx.s
を指定します。
https://github.com/minio/c2goasm/blob/0325a40cfd1fc6a5097e69eaf0292990eb6cee6a/c2goasm.go#L252
ここまでが一連の流れです。
ベンチマーク
Pure Goで実装した次の関数とベンチマークにより比較します。
package c2goasm_sample func SumFloat64(a []float64) float64 { var sum float64 for i := range a { sum += a[i] } return sum }
ベンチマークのコードは次の通り。
package c2goasm_sample_test import ( "math/rand" "testing" ) func init() { rand.Seed(0) } func initializeFloat64Array(n int) []float64 { var max float64 = 1024 var min float64 = 0 x := make([]float64, n) for i := 0; i < n; i++ { x[i] = rand.Float64() * (max - min) + min } return x } func benchmarkFloat64Sum(b *testing.B, n int) { x := initializeFloat64Array(n) b.ResetTimer() for i := 0; i < b.N; i++ { SumFloat64(x) } } func benchmarkFloat64SumAvx2(b *testing.B, n int) { x := initializeFloat64Array(n) b.ResetTimer() for i := 0; i < b.N; i++ { SumFloat64Avx2(x) } } func BenchmarkSumFloat64_256(b *testing.B) { benchmarkFloat64Sum(b, 256) } func BenchmarkSumFloat64_1024(b *testing.B) { benchmarkFloat64Sum(b, 1024) } func BenchmarkSumFloat64_8192(b *testing.B) { benchmarkFloat64Sum(b, 8192) } func BenchmarkSumFloat64_AVX2_256(b *testing.B) { benchmarkFloat64SumAvx2(b, 256) } func BenchmarkSumFloat64_AVX2_1024(b *testing.B) { benchmarkFloat64SumAvx2(b, 1024) } func BenchmarkSumFloat64_AVX2_8192(b *testing.B) { benchmarkFloat64SumAvx2(b, 8192) }
実行結果
$ go test -bench . goos: darwin goarch: amd64 pkg: github.com/c-bata/sandbox-go/c2goasm BenchmarkSumFloat64_256-4 5000000 277 ns/op BenchmarkSumFloat64_1024-4 1000000 1205 ns/op BenchmarkSumFloat64_8192-4 100000 10401 ns/op BenchmarkSumFloat64_AVX2_256-4 2000000 768 ns/op BenchmarkSumFloat64_AVX2_1024-4 500000 2872 ns/op BenchmarkSumFloat64_AVX2_8192-4 100000 23946 ns/op PASS ok github.com/c-bata/sandbox-go/c2goasm 10.474s
遅くなっちゃった...
追加調査
Apache Arrowでの利用事例がある以上、なにかコンパイラーオプションとかが理由で性能が十分に引き上げられていない。気になってる点は次のあたり
- xmm0とか使ってるし、vxorpsとかvmovsdとかもAVX2じゃなくてSSEとかの命令を使っていそう
- LLVM Auto-Vectorizationのドキュメント読みつつ、Clangのコンパイルオプション見直したほうが良い。
- https://github.com/apache/arrow/blob/master/go/arrow/math/float64_avx2_amd64.s とかはちゃんとAVX2使ってることが確認できる
- LLVMの最適化に頼らずintrinsicsを使って実装した処理で性能がちゃんとあがるのかも試してみたけど、実行時にセグフォで死んだ
- https://github.com/c-bata/sample-c2goasm/blob/master/_lib/add_float32_avx2_intrinsics.s を見る限りアセンブリ生成まではおそらく問題なくて、Go側の呼び出し時のポインタの扱いに問題がある。
- Apache Arrowのパフォーマンスがちゃんとあがってるのかベンチマークとってみる。
追記: うまくいきました。
ひとまずClang 7.0.1+O2オプション+pragmaヒントでうまくいった。Clangのバージョンとか最適化オプションによって結構動かなくなってしまうみたい。細かい調査はまた今度。
— Masashi Shibata (@c_bata_) 2019年3月17日
試しにClangでアセンブリだしてみてだめなら自分でAVX2のイントリンシック使って書くとかになりそうhttps://t.co/zp22jGNEAo
Clang-7.0.1を利用する際には、次のコンパイルオプションを使用してください。
$ /usr/local/Cellar/llvm/7.0.1/bin/clang -S -O2 -mavx2 -masm=intel -mno-red-zone -mstackrealign -mllvm -inline-threshold=1000 -fno-asynchronous-unwind-tables -fno-exceptions -fno-rtti -c sum_float64.c
追記: SSEのコードはなぜ遅くなっていたのか。
SSEでも遅くはならないんじゃないかなというのが気になっていたけれど、なんとなく原因がイメージついた気がします。 InfluxDataの記事にあるとおりsumの計算を最適化してCで直接書くとAVX2のコードはこんな感じになります。
void sum_float64_avx_intrinsics(double buf[], size_t len, double *res) { __m256d acc = _mm256_set1_pd(0); for (int i = 0; i < len; i += 4) { __m256d v = _mm256_load_pd(&buf[i]); acc = _mm256_add_pd(acc, v); } acc = _mm256_hadd_pd(acc, acc); // a[0] = a[0] + a[1], a[2] = a[2] + a[3] *res = _mm256_cvtsd_f64(acc) + _mm_cvtsd_f64(_mm256_extractf128_pd(acc, 1)); }
重要なのはこの _mm256_hadd_pd
の処理で こちらの説明にあるように詰め込まれている要素の加算 を担当します。128 bitsしかないSSE用のレジスタ xmm0 とかだと8 bytes 必要な double の値を詰め込んでも2つしかのらないので、それにたいして hadd_pd みたいな処理(これはAVX2の処理ですがそれに相当する処理がSSEにもあるのかと思います) をすると全く並列化されてないことになります。
細かいところはアセンブリが使ってる命令をみればはっきりしそうです。
-
GoのポインタをCに渡せないようにポインタのバリデーション処理が走るなど、GC側の都合による性能劣化が発生するようです。詳細は Why cgo is slow @ CapitalGo 2018 - Speaker Deck をご覧ください。↩