c-bata web

@c_bata_ のメモ。python と Go が多めです。

Djangoライブラリのテストを setup.py testで実行するためのTestCommand

Djangoのアプリケーションをライブラリとして公開する際の、ファイル構成はいろいろな選択肢があります。 通常のプロジェクトの始め方と同じように django-admin startproject でプロジェクトを作成しその中に作成したアプリケーションを公開する場合や、プロジェクトは用意せずに django-admin startapp your-awesome-library でアプリケーションだけを作成する場合などがあります。

基本的には前者のやり方が悩みどころが少なく簡単ですが、パッケージ構成は後者のほうがスッキリするかもしれません。このあたりは正解がなくOSSのプロジェクトによっても様々なので、好きな方を選ぶ必要があるかと思います。最近 django-httpbench というライブラリを公開したのですが、そこでは後者を選びました。

ただテストの実行方法に関して悩みどころがあります。 DJANGO_SETTINGS_MODULE=test_settings python3 -m django test のように実行していましたが、Pythonパッケージとしては python3 setup.py test で統一的にテストを実行できるほうが嬉しいでしょう。jazzbandにあるリポジトリを眺めているとその多くが setup.py test には対応していませんでいたが、対応しているものの中にはテストファイル内で django.setup などを呼び出しているものがありました。あまりそういうやり方も取りたくないので、Pytest のやり方を参考にDjango用のクラスを用意しました。

settings.py は --settings オプションで manage.py コマンドと同じように指定できるようにしました。 test_suites に指定されたパッケージを TestRunnerのtest_labelsに渡して実行しています。

import os
import sys

from setuptools import setup, find_packages
from setuptools.command.test import test as TestCommand

class DjangoTestCommand(TestCommand):
    user_options = TestCommand.user_options + [
        ('settings=', None, "The Python path to a settings module"),
    ]

    def initialize_options(self):
        TestCommand.initialize_options(self)
        self.settings = ''

    def run_tests(self):
        # import here, cause outside the eggs aren't loaded
        import django
        from django.conf import settings
        from django.test.utils import get_runner

        if self.settings:
            os.environ['DJANGO_SETTINGS_MODULE'] = self.settings
        django.setup()
        TestRunner = get_runner(settings, test_runner_class=self.test_runner)
        test_runner = TestRunner()
        test_labels = [self.test_suite]
        failures = test_runner.run_tests(test_labels)
        if failures:
            sys.exit(1)


setup(
    name='djangohttpbench',
    ...,
    install_requires=[
        'Django',
        'requests',
    ],
    test_suite='httpbench.tests',
    tests_require=[],
    cmdclass={'test': DjangoTestCommand}
)

全体: django-httpbench/setup.py at master · c-bata/django-httpbench · GitHub

実行する際はこんな感じです。

$ python setup.py test --settings=test_settings
...
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.002s

OK
Destroying test database for alias 'default'...

または DJANGO_SETTINGS_MODULE=test_settings python setup.py test ともできます。 また setup.py test の --test-runner オプションとかはそのまま付けることができるので次のように明示的に指定することも可能です。

$ python setup.py test --settings=test_settings --test-runner=django.test.runner.DiscoverRunner
...
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.002s

OK
Destroying test database for alias 'default'...

メモ: djangojaでのやりとり

hirokiky [9:00 AM]
テスト用のSettings指定するかなぁ

tokibito [12:09 PM]
pytest-djangoでテスト用プロジェクトのsettings指定に落ち着いたよ
テスト実行はCIと、手元ではtox使っているから、setup.pyにこだわらなくなった
https://github.com/tokibito/django-ftpserver
GitHub
tokibito/django-ftpserver
FTP server application that used user authentication of Django. - tokibito/django-ftpserver
こういう感じ

tokibito [1:13 PM]
Django向けのライブラリを作るとき、アプリやプロジェクトがあることを前提とすること多いし、独自の仕組にしてDjangoバージョン上がったときに動かなくなること結構あった

c-bata [1:51 PM]
kyさんもsetup.pyにはこだわらずに実行してる感じですかね
> テスト実行はCIと、手元ではtox使っているから、setup.pyにこだわらなくなった

なるほど

hirokiky [1:59 PM]
ですねー。toxで書いちゃう。
でも手元で1環境でテスト実行したいときに、 DJANGO_SETTINGS… って書いてだりーなぁって思う
--pdb つけたいときとか。

c-bata [2:10 PM]
なるほどー
setup.py test対応するのって統一的なインターフェイスがあるといいよねって話かと思ってますが、たしかにtoxとか使ってるプロジェクトなら `tox` とだけ打てばテスト実行できますしね

hirokiky [2:15 PM]
そうねぇ。あんまりsetup.py test使わないのも何か悲しいけどね。

tokibito [2:38 PM]
testコマンドでtox叩こうぜ

toxでやるならこんな感じでもいいかもですね

import os
import subprocess
import sys

from setuptools import setup, find_packages
from setuptools.command.test import test as TestCommand

class ToxTestCommand(TestCommand):
    user_options = [
        ('env=', 'e', "work against specified environments (ALL selects all)"),
    ]

    def initialize_options(self):
        TestCommand.initialize_options(self)
        self.env = None

    def run_tests(self):
        popenargs = ['tox']
        if self.env:
            popenargs += ['-e', self.env]
        sys.exit(subprocess.call(popenargs))


setup(
    ...
    test_suite='',
    tests_require=['tox'],
    cmdclass={'test': ToxTestCommand}
)

tox使う以上 test_suite は使われないのですが、とりあえず動きます

$ python setup.py test -e py37
....
----------------------------------------------------------------------
Ran 4 tests in 0.004s

OK
Destroying test database for alias 'default'...
___________________________________________________________________________________________ summary ____________________________________________________________________________________________
  py37: commands succeeded
  congratulations :)

LLVMのauto-vectorizationとc2goasmによるGo Plan9 Assemblyの生成によるSIMD最適化

InfluxDBの開発チームはApache Arrowの技術に注目していて、ArrowのGo実装の開発にも積極的に参加しています。Stuart Carine (InfluxDBの開発チームメンバー)がApache ArrowのGo実装に取り入れたc2goasmとLLVMを使った最適化が以前話題になりました。

www.influxdata.com

c2goasm はClangで生成したアセンブリをGo Plan9 Assemblyに変換できる汎用的なコマンドラインツールです。しかしその活用事例はほとんどが、Goコンパイラーが行っていないSSEやAVX命令を用いたSIMD最適化です。この記事では Intel AVX (Advanced Vector Extensions) によるSIMD演算 - c-bata web でも解説した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

c2goasm の使い方は次のとおりです。

  1. C/C++で記述した関数からClangでアセンブリを生成
  2. Goのバインディング用のシグネチャを定義
  3. c2goasmのコマンドラインツールでGo Plan9 Assemblyを生成

ツールとしての作り込みが少し雑な印象はありハマりどころもありますが、この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 を利用していることからLLVMSIMD命令を活用していることがわかります。サブルーチン名 _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での利用事例がある以上、なにかコンパイラーオプションとかが理由で性能が十分に引き上げられていない。気になってる点は次のあたり

追記: うまくいきました。

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 (ADDISON-WESLEY PROFESSIONAL COMPUTING SERIES)

プログラミング言語Go (ADDISON-WESLEY PROFESSIONAL COMPUTING SERIES)


  1. GoのポインタをCに渡せないようにポインタのバリデーション処理が走るなど、GC側の都合による性能劣化が発生するようです。詳細は Why cgo is slow @ CapitalGo 2018 - Speaker Deck をご覧ください。

Intel AVX (Advanced Vector Extensions) によるSIMD演算

SIMD(Single Instruction Multiple Data)は、名前の通り複数のデータを1命令で処理する方式を指します。各プロセッサコアに異なる命令を供給する機構が必要なMIMDと比べ、SIMDは必要なトランジスタ数が少なく小さな面積でプロセッサを設計することができるため、GPUにおける並列処理のパラダイムとしても主流になっています (SIMTは今は扱わない)。

Intel CPUのMMX命令やSSE命令、AVX命令はSIMD方式の演算命令になります。動画配信チームからML系の研究チームに異動したのですが、研究成果を実用レベルのアプリケーション実装に落とし込めるよう今回はこのAVX命令について整理しました。この記事の実行環境はOSがmacOS, cpuは intel x86_64 です。AVXの解説書としては日本語だと下記の書籍がかなり整理されていますが、基本的に x86 前提のコードなので参考にする際は適宜置き換えてください。

AVX命令入門―Intel CPUのSIMD命令を使い倒せ

AVX命令入門―Intel CPUのSIMD命令を使い倒せ

SSEを用いた行列の加算演算

x64ではインラインアセンブリが利用できないため、アセンブリ命令をマクロで記述できるようにしたイントリンシックを利用します。マクロを覚えるのが面倒でx64アセンブリに慣れているなら直接関数シンボルを記述してgccでオブジェクトファイルを生成し、ldでリンクする方法もとれなくはないはずです。ただしポータビリティの観点からも基本的にはイントリンシックを素直に覚えるのがいいでしょう。

SIMD命令の1つであるSSEをまずは使ってみます。ヘッダファイル xmmintrin.h にSSE用のアセンブリに展開するためのマクロが入っているので、そちらincludeして次のように行列加算を実行します。

#include <stdio.h>
#include <xmmintrin.h>

int main(void) {
    __m128 a = {1.0f, 2.0f, 3.0f, 4.0f};
    __m128 b = {1.1f, 2.1f, 3.1f, 4.1f};
    float c[4];
    __m128 ps = _mm_add_ps(a, b); // add
    _mm_storeu_ps(c, ps); // store

    printf("  source: (%5.1f, %5.1f, %5.1f, %5.1f)\n",
            a[0], a[1], a[2], a[3]);
    printf("  dest. : (%5.1f, %5.1f, %5.1f, %5.1f)\n",
           b[0], b[1], b[2], b[3]);
    printf("  result: (%5.1f, %5.1f, %5.1f, %5.1f)\n",
           c[0], c[1], c[2], c[3]);
    return 0;
}

SSEの命令では128ビットのレジスタが利用できます。浮動小数点数 float型は4 bytes(=32 bits)であることから4つの要素を一度に演算できます。これを実行してみます。

$ gcc -o avx -Wall -O0 main.c
$ ./avx-
  source: (  1.0,   2.0,   3.0,   4.0)
  dest. : (  1.1,   2.1,   3.1,   4.1)
  result: (  2.1,   4.1,   6.1,   8.1)

AVX (AVX2)を用いた行列の加算演算

SSEではSIMD用のレジスタが128ビットであったため、floatであれば4要素までしか一度に演算できませんでした。AVX命令はビット幅が256ビットへ拡張され、3オペランド命令書式が採用され演算性能を大幅に向上することができました。またメモリオペランドを用いた演算時にアドレスのアライメントを要求しなくなったためメモリ配置の自由度があがりました(その場合性能が少し低下します)。さらにその後追加されたAVX2では浮動小数点だけでなく、整数の演算がサポートされました。

基本的にAVX2がサポートされている環境ではこちらを利用することが望ましいでしょう。AVXやAVX2のアセンブリに展開するためのマクロを使うときは immintrin.h をincludeします。

#include <stdio.h>
#include <immintrin.h>

int main(void) {
    __m256 a = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f};
    __m256 b = {1.1f, 2.1f, 3.1f, 4.1f, 5.1f, 6.1f, 7.1f, 8.1f};
    __m256 c;

    c = _mm256_add_ps(a, b);

    printf("  source: (%5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f)\n",
            a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7]);
    printf("  dest. : (%5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f)\n",
            b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]);
    printf("  result: (%5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f, %5.1f)\n",
           c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7]);
    return 0;
}

AVXではSIMD用のレジスタが256ビットになっているため、floatの演算であれば 256 bits / 32 bits(floatは4bytes) で8要素を一度に演算できます。浮動小数点 float ではなく倍精度浮動小数点 double を使いたいなら __m128d 、整数を使いたいなら __m128i を使用します。倍精度浮動小数点は8bytesを使うため4要素しか扱えないことに注意してください。

参照: https://www.codingame.com/playgrounds/283/sse-avx-vectorization/what-is-sse-and-avx

これをコンパイルしてみます。

$ gcc -o avx2 -Wall main.c-
main.c:9:9: error: always_inline function '_mm256_add_ps' requires target feature 'xsave', but would be inlined into function 'main' that is compiled without support for 'xsave'
    c = _mm256_add_ps(a, b);
        ^
1 error generated.

コンパイルに失敗しました。 こちらの記事 によるとCPUがサポートしている機能は次のコマンドにより確認できます。

$ sysctl -a | grep machdep.cpu.features
machdep.cpu.features: FPU VME DE PSE TSC MSR PAE MCE CX8 APIC SEP MTRR PGE MCA CMOV PAT PSE36 CLFSH DS ACPI MMX FXSR SSE SSE2 SS HTT TM PBE SSE3 PCLMULQDQ DTES64 MON DSCPL VMX EST TM2 SSSE3 FMA CX16 TPR PDCM SSE4.1 SSE4.2 x2APIC MOVBE POPCNT AES PCID XSAVE OSXSAVE SEGLIM64 TSCTMR AVX1.0 RDRAND F16C

たしかにSSEやSSE2、SSE4.1、SSE4.2、AVX1.0 は存在しますが、AVX2がここにはありません。ただしCPUとしてはAVX2をサポートされている場合があり、次のコマンドで出てきた命令は特別なコンパイラーオプションを与えることで使用できるようです。

$ sysctl -a | grep machdep.cpu.leaf7_features
machdep.cpu.leaf7_features: SMEP ERMS RDWRFSGS TSC_THREAD_OFFSET BMI1 AVX2 BMI2 INVPCID SMAP RDSEED ADX IPT SGX FPU_CSDS MPX CLFSOPT

AVX2 が何らかのコンパイラーオプションを与えることで使用できることがわかりました。こちらの記事 によるとgccでは -mavx2 オプションが利用できます。

$ gcc -o avx2 -mavx2 -Wall main.c 
$ ./avx2
  source: (  1.0,   2.0,   3.0,   4.0,   5.0,   6.0,   7.0,   8.0)
  dest. : (  1.1,   2.1,   3.1,   4.1,   5.1,   6.1,   7.1,   8.1)
  result: (  2.1,   4.1,   6.1,   8.1,  10.1,  12.1,  14.1,  16.1)

AVX2の命令と利用できるレジスタ解説

先程のAVX2を利用したプログラムのアセンブリを出力しておく。これをもとにAVX2のレジスタ構成と命令セットを解説

$ gcc -S -mavx2 -Wall -O0 avx2.c
$ less avx2.s

まず注目するべきなのは次のアセンブリ群。

LCPI0_0:
        .long   1065353216              ## float 1
        .long   1073741824              ## float 2
        .long   1077936128              ## float 3
        .long   1082130432              ## float 4
        .long   1084227584              ## float 5
        .long   1086324736              ## float 6
        .long   1088421888              ## float 7
        .long   1090519040              ## float 8

... 略

## %bb.0:
        ... 略
        vmovaps LCPI0_0(%rip), %ymm0    ## ymm0 = [1.000000e+00,2.000000e+00,3.000000e+00,4.000000e+00,5.000000e+00,6.000000e+00,7.000000e+00,8.000000e+00]
        vmovaps %ymm0, 160(%rsp)

オブジェクトファイル内の LCPI0_0 セクションに書き込まれたfloat配列の情報を ymm0 と呼ばれるCPU上のレジスタ領域にコピーしていることがCのコードから想像できる。レジスタ構成は次のようになる。

参照: https://de.wikipedia.org/wiki/Advanced_Vector_Extensions

vmovaps命令によるレジスタ配置が続いたあと、次の命令が存在する。

        vaddps  %ymm1, %ymm0, %ymm0

オペランドが3つあることから AVX の命令であること、 add という文字から加算演算を意味することが予測できる。Intelのページ によるとsingle-precisionな浮動小数点(つまりfloat)の加算演算を行いその結果を第3オペランドで指定したレジスタに格納する。つまりymm1とymm0とよばれる256bitsのレジスタ領域に格納されている8つのfloat値を加算し、その結果がymm0に格納されることを意味している。

加算演算はこれで完了なので、あとは適切にその結果を変数 c に格納すればいい。少しアセンブリが冗長なように見えなくもないがなにかそこはもう少し読むと理由があるかも。パフォーマンスが思ったほど上がらなければ確認したほうがいいかも。

発展: AVX-512

colfaxresearch.com

たまたま見つけたこちらのページによるとどうやらAVX2はSkylakeにおいてもはやLegacyな命令セットで、AVX-512は512 bitのレジスタ幅を持っているようです。AVX2に比べてかなりの性能向上が期待できます。ただ $ sysctl -a | grep machdep.cpu.leaf7_features の結果にAVX-512が現れていないので対応状況から調べる必要がありそうです。 simdjson などもまだAVX2を使っているようなのでなにか対応状況などの背景から利用が難しいのかも。

また図を見る限りAVX-512の中にもかなりいろいろな派生があるようで、まとめると長くなりそうなのでそちらの調査結果はまた別記事にて整理しようと思います。