GoのHFTシステムにおいて、goroutineをOSスレッドに固定することは、NUMAペナルティとロック競合を大幅に減少させることができます。この記事では、runtime.LockOSThread()を活用し、スレッドのアフィニティを管理し、マルチソケットアーキテクチャ向けにGoコードを最適化する方法を探ります。

NUMAの悪夢

goroutineの固定の詳細に入る前に、HFTシステムにとってNUMA(非均一メモリアクセス)アーキテクチャがなぜ厄介なのかを簡単に振り返りましょう:

  • メモリアクセスのレイテンシは、どのCPUコアがどのメモリバンクにアクセスするかによって異なります
  • Goのスケジューラは、デフォルトではgoroutineのスケジューリング時にNUMAトポロジーを考慮しません
  • これにより、頻繁なクロスソケットメモリアクセスが発生し、パフォーマンスが低下する可能性があります

HFTの世界では、ナノ秒単位の違いが利益と損失を分けることがあります。しかし、心配しないでください。この問題を解決するためのツールがあります!

Goroutineの固定:秘密のソース

GoでNUMAの問題を軽減する鍵は、goroutineを特定のOSスレッドに固定し、それを特定のCPUコアにバインドすることです。これにより、goroutineがNUMAノードを越えて移動しないようにします。以下の方法でこれを実現できます:

1. 現在のgoroutineをそのOSスレッドに固定する


func init() {
    runtime.LockOSThread()
}

この関数呼び出しは、現在のgoroutineを実行中のOSスレッドに固定します。プログラムの開始時や固定が必要なgoroutineでこれを呼び出すことが重要です。

2. スレッドアフィニティを設定する

goroutineをOSスレッドに固定したら、このスレッドをどのCPUコアで実行するかをOSに伝える必要があります。残念ながら、Goにはこれを行うネイティブな方法がないため、cgoを使った工夫が必要です:


// #include <pthread.h>
// #include <stdlib.h>
import "C"
import "unsafe"

func setThreadAffinity(cpuID int) {
    runtime.LockOSThread()
    
    var cpuset C.cpu_set_t
    C.CPU_ZERO(&cpuset)
    C.CPU_SET(C.int(cpuID), &cpuset)
    
    thread := C.pthread_self()
    _, err := C.pthread_setaffinity_np(thread, C.size_t(unsafe.Sizeof(cpuset)), &cpuset)
    if err != nil {
        panic(err)
    }
}

この関数は、POSIXスレッドAPIを使用して、現在のスレッドのアフィニティを特定のCPUコアに設定します。特定のコアに固定する必要がある各goroutineからこの関数を呼び出す必要があります。

すべてをまとめる:高性能なマーケットデータパイプライン

これで基本的な要素が揃ったので、実際のHFTシナリオにどのように適用できるかを見てみましょう。ここでは、受信ティックを処理し、基本的な統計を計算するシンプルなマーケットデータパイプラインを作成します。


package main

import (
    "fmt"
    "runtime"
    "sync"
    "time"
)

type MarketData struct {
    Symbol string
    Price  float64
}

func marketDataProcessor(id int, inputChan <-chan MarketData, wg *sync.WaitGroup) {
    defer wg.Done()
    
    // このgoroutineを特定のCPUコアに固定する
    setThreadAffinity(id % runtime.NumCPU())
    
    var count int
    var sum float64
    
    start := time.Now()
    for data := range inputChan {
        count++
        sum += data.Price
        
        if count % 1000000 == 0 {
            avgPrice := sum / float64(count)
            elapsed := time.Since(start)
            fmt.Printf("Processor %d: Processed %d ticks, Avg Price: %.2f, Time: %v\n", id, count, avgPrice, elapsed)
            start = time.Now()
            count = 0
            sum = 0
        }
    }
}

func main() {
    runtime.GOMAXPROCS(runtime.NumCPU())
    
    numProcessors := 4
    inputChan := make(chan MarketData, 10000)
    var wg sync.WaitGroup
    
    // マーケットデータプロセッサを開始
    for i := 0; i < numProcessors; i++ {
        wg.Add(1)
        go marketDataProcessor(i, inputChan, &wg)
    }
    
    // 受信マーケットデータをシミュレート
    go func() {
        for i := 0; ; i++ {
            inputChan <- MarketData{
                Symbol: fmt.Sprintf("STOCK%d", i%100),
                Price:  float64(i % 10000) / 100,
            }
        }
    }()
    
    wg.Wait()
}

この例では、複数のマーケットデータプロセッサを作成し、それぞれを特定のCPUコアに固定しています。このアプローチにより、マルチコアシステムの利用を最大化しながら、NUMAペナルティを最小限に抑えることができます。

Goroutine固定の利点と欠点

goroutineの固定に全面的に取り組む前に、トレードオフを理解することが重要です:

利点:

  • マルチソケットシステムでのNUMAペナルティの削減
  • キャッシュの局所性の向上とキャッシュスラッシングの削減
  • CPUコア間のワークロード分散の制御が向上
  • HFTシナリオでの大幅なパフォーマンス向上の可能性

欠点:

  • コードとシステム設計の複雑さの増加
  • 慎重に管理しないと不均一な負荷分散の可能性
  • Goの組み込みスケジューリングの利点の一部を失う
  • スレッドアフィニティ管理にOS固有のコードが必要な場合がある

影響の測定:前後の比較

goroutineの固定の利点を真に理解するには、実装前後のシステムのパフォーマンスを測定することが重要です。注目すべき主要な指標は次のとおりです:

  • レイテンシのパーセンタイル(p50, p99, p99.9)
  • スループット(1秒あたりの処理メッセージ数)
  • コア間のCPU使用率
  • メモリアクセスパターン(Intel VTuneやAMD uProfなどのツールを使用)

プロのヒント:pprofのようなツールを使用して、goroutineの固定を実装する前後のアプリケーションのCPUとメモリのプロファイルを生成します。これにより、最適化がシステムの動作にどのように影響しているかについての貴重な洞察が得られます。

固定を超えて:HFTワークロードのための追加の最適化

goroutineの固定は強力な技術ですが、HFTワークロード向けにGoを最適化する際のパズルの一部に過ぎません。考慮すべき追加の戦略は次のとおりです:

1. メモリ割り当ての最適化

割り当てを減らしてガベージコレクションの停止を最小限に抑える:

  • 頻繁に割り当てられるオブジェクトにはsync.Poolを使用
  • 固定サイズのデータにはスライスの代わりに配列を検討
  • 可能な場合はバッファを事前に割り当て

2. ロックフリーデータ構造

アトミック操作とロックフリーデータ構造を使用して競合を減らす:


import "sync/atomic"

type AtomicFloat64 struct{ v uint64 }

func (f *AtomicFloat64) Store(val float64) {
    atomic.StoreUint64(&f.v, math.Float64bits(val))
}

func (f *AtomicFloat64) Load() float64 {
    return math.Float64frombits(atomic.LoadUint64(&f.v))
}

3. SIMD命令

市場データの並列処理のためにSIMD(単一命令、複数データ)命令を活用します。Goには直接のSIMDサポートはありませんが、アセンブリやcgoを使用してこれらの強力な命令を利用できます。

まとめ:HFTにおけるGoの未来

見てきたように、少しの努力とgoroutineの固定のような高度な技術を使えば、GoはHFTの分野で強力なツールとなり得ます。しかし、旅はここで終わりません。Goチームはランタイムとスケジューラの改善に常に取り組んでおり、将来的にはこれらの手動の最適化が不要になるかもしれません。

覚えておいてください、早すぎる最適化はすべての悪の根源です。goroutineの固定のような高度な技術に飛び込む前に、まずアプリケーションをプロファイルして実際のボトルネックを特定してください。そして、最適化を行う際には、測定、測定、測定を忘れずに!

トレードがうまくいきますように、そしてgoroutineが常に正しいCPUコアに帰ることができますように!

「HFTの世界では、ナノ秒が重要です。しかし、ソフトウェアエンジニアリングの世界では、可読性と保守性がさらに重要です。バランスを取り、成功を収めましょう。」 - 賢い古いゴーファー

さらなる学び

さあ、NUMAノードを征服しに行きましょう!そして、強力な力には大きな責任が伴うことを忘れずに。新たに得たgoroutine固定のスキルを賢く使いましょう!