Upgrade to Pro — share decks privately, control downloads, hide ads and more …

[GoCon2026] When Goroutines Are Not Enough: Run...

[GoCon2026] When Goroutines Are Not Enough: Runtime Locality in High-Throughput Go

https://gocon.jp/2026/en/timetable/1251920/
Go Conference 2026 で登壇 した時の資料です

Avatar for Takeru Hayasaka

Takeru Hayasaka

September 10, 2026

More Decks by Takeru Hayasaka

Other Decks in Programming

Transcript

  1. Whoami • 早坂 彪流 (Hayasaka Takeru|@takemioIO) • Senior Software Engineer

    at SAKURA internet Inc. ◦ Currently on secondment to BBSakura Networks.Inc • Develops and operates mobile core ◦ Previously wrote game console firmware at a game company :-) • Co-Organizer of the eBPF Japan Meetup • Go Conference 2026 Staff ◦ 昨年のGoCon2025では「Goで体感するMultipath TCP ― Go 1.24 時代の MPTCP Listener を理解する」 という話で登壇させてもらってました🐈 2
  2. この発表のモチベーション • 目的: Goで⾼スループットなデータ処理を実現したい ◦ • e.g. メトリクスデータやパケットの加工・集計・取り込み 課題: データと処理をどう分け、goroutineにどう担当させるか。

    その構成で性能が足りないとき、何を調べ、どこを変えるか。 ◦ 例えばgoroutineへ仕事を分けても、実⾏するCPUまでは決まりません。 具体的にはCPUバウンドだとGo任せだと足りない可能性あります。 このような場合に使える改善手法をボトルネック分析と合わせて紹介します ◦ • 改善提案例 ▪ スケジューラ: 割り込み禁止、CPUコア固定 ▪ ほか: 不要なコピー削減、バッチ処理 この発表で伝えること: 設計と性能改善に使える手がかり ◦ 処理をどんな単位に分け、goroutineにどう担当させるか? ◦ GoやOSに任せる範囲をどう決めるか? ◦ CPUアフィニティを変えるとき、何を計測して効果を判断するか 3
  3. Agenda 1. 複数のgoroutineへ仕事を分担させる設計 a. 独立に進められる単位を考え、データと処理を分ける。 2. 実例:packet captureツールの設計と性能調査 a. ツールの構造と、pprofで調べたCPU時間の使い道。

    3. 実例:packet captureツールの性能改善 a. 中断の設定・読み出しの実装・CPU配置を変えて比べる。 4. ストリーム処理への応用 a. 処理の偏りや待機の仕方、CPU配置をどう調べるか。 4
  4. Go Blogが紹介するストリーム処理 “Go’s concurrency primitives make it easy to construct

    streaming data pipelines that make efficient use of I/O and multiple CPUs.” cf. Go Concurrency Patterns: Pipelines and cancellation, Sameer, 2014 「Goの並行処理の仕組みで、I/Oや複数のCPUを効率よく利用するストリーム 処理を構成できる」 => その基本形を見てみよう! 6
  5. pipeline とは 前の段階が出力するchannelを次の段階へ渡す処理パターンのこと 例) 与えられたデータを 2 乗して表示するパイプライン gen とsq は、それぞれ内部でgoroutineを起動し、channelを返す関数

    Step 1 入力の準備 処理するデータを渡す gen Step 3 Step 2 c 2, 3 加工 受け取った値から 計算する sq c := gen(2, 3) Go Blogの最小例: 2と3を二乗して表示する out 4, 9 out := sq(c) 結果の利用 表示・保存などを行う main fmt.Println(<-out) // 4 fmt.Println(<-out) // 9 では一つの段階を複数workerで分担するのはどうすれば良いのか? 7 cf. https://go.dev/blog/pipelines#squaring-numbers
  6. 複数のgoroutineに処理を分担させてみよう (1) 例) ファイル path を受け取って、それぞれの checksum を計算する処理 結果を集める 読み込んで計算する

    列挙する paths channel walkFiles ファイル名を列挙 pathsへ送る digester goroutine 1 a.txt の計算 c channel digester goroutine 2 b.txt の計算 MD5All cから結果を受信 mapに結果を格納 goroutine は共通の channel からファイル名を取得し、読み込みと計算を行うworker として機能する ファイルごとの計算は独立しているので、複数workerで分担できる。 8 cf. https://go.dev/blog/pipelines#digesting-a-tree
  7. 複数のgoroutineに処理を分担させてみよう (2) ファイル同士の計算は独立しているので、どのworkerでも処理できます。 一つのpaths channelへファイル名を送り、受け取ったworkerに計算を任せます。 c (channel) 計算結果 paths (channel)

    ファイル名 digester goroutine 1 a.txt b.txt 0xA c.txt 0xB 0xC digester goroutine 2 同じpathsを各workerへ渡す 各workerがpathsから受け取る go func() { digester(done, paths, c) wg.Done() }() for path := range paths { data, err := ioutil.ReadFile(path) // … checksumを計算して結果を送る } MD5All内。workerを20個起動するloopから抜粋。 digester内。読み込み後の処理は省略。 9 cf. https://go.dev/blog/pipelines#digesting-a-tree
  8. 処理が独立していない場合はどうなる……? ファイルの計算は独立だったため、一件ずつ別のworkerへ渡せたが、 同じ通信のpacketや同じ系列のサンプルをまとめて扱いたい場合は? ⾝近な処理の例 まとめる単位 ⼀緒に扱いたいもの 通信解析:Suricata flow(5-tupleなど) 同じ通信のpacketと解析状態 metrics送信:Prometheus

    series(データ系列) 同じ系列のサンプルの送信順 ログの⾏結合:Fluent Bit stream(ログの流れ) ⼀つのメッセージを構成する複数⾏ メッセージ処理:Kafka partition その中の順序と、読み進めた位置 https://docs.suricata.io/en/suricata-8.0.6/performance/packet-capture.html#load-balancing https://prometheus.io/docs/specs/prw/remote_write_spec/#ordering https://docs.fluentbit.io/manual/data-pipeline/parsers/multiline-parsing https://kafka.apache.org/43/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html 10
  9. remote write の実装を見てみよう remote writeの送信をどう分担しているか、実装を⾒ます。 Prometheus v3.5.0のqueue_manager.goから、⼆つの箇所を抜粋します。 同じseries IDを、同じqueueへ振り分ける Prometheusはseriesを数値の内部ID(ref)で識別します。

    queue数で割った余りから送り先を選ぶ。 shard := uint64(ref) % uint64(len(s.queues)) queueごとに、送信するgoroutineを起動する go s.runShard(hardShutdownCtx, i, newQueues[i]) Prometheus v3.5.0: enqueue / start 12
  10. series 内の順序を保ちながら並行で処理できる batch(複数のサンプル)を、queue内のchannelで受け渡している様子 送信側: shard 0 Prometheus(Go) goroutine Series IDで

    振り分け WALから読む A:t1 B:t1 A:t2 A: ID 100 B: ID 101 queue_config: min_shards: 2 # 下限 max_shards: 8 # 上限 capacity: 10000 enqueue ref % 2 100->0 101->1 queue 0 A(t1)->A(t2) shard 1 queue 1 B(t1) runShard 受信 → 圧縮・送信 goroutine 外部の受信先 HTTP 同じ送信先 Endpoint runShard 受信 → 圧縮・送信 shard数: queue+goroutineを何組動かすか ← 送信状況に応じて2〜8組で⾃動調整する設定例 各queueの容量: サンプルを何件溜められるか ← queue⼀つあたり10,000サンプルの設定例 cf.https://prometheus.io/docs/practices/remote_write/ 13
  11. 第1章のまとめ goroutine を用いたストリーム処理について…… 一つの処理段階を分担する方法には 2 種類あり、 • 独⽴した仕事:共有queueを複数workerで読む ◦ 受け取ったworkerがその⼀件を処理する

    ◦ ファイルの checksum 計算の例 • 順序や状態をまとめたい仕事:keyごとに送り先を決める ◦ key (seriesのように、まとめるデータを識別する値) に基づき、 同じkeyは同じqueueへ送り、⼀つのgoroutineがそれを処理する ◦ Prometheus の remote write の例では、送信順を保ちながら処理することが 可能になった 14
  12. bpf-ninja:Goで作ったpacket captureツール eBPFでpacket情報を取得し、それをGoアプリケーションで読み出して処理し ファイルに保存するツール (eBPFは、Linuxカーネル内でプログラムを動かし、観測や処理を追加する仕組み) Kernel 側 User 側 event

    eBPF packetを取得する packet packetの 情報 書く側: writer Go App eventを読み出す 読む側: reader pcap file eBPFから渡されるpacketの情報を、以降「event」と呼びます logo license: eBPF and the eBPF logo are trademarks of The Linux Foundation. The Go Logo is a trademark of Google LLC. 16
  13. パケットキャプチャ設定の様子 eth0で受けたpacketを、フィルタ式で絞り込んで保存する例 pcapファイルはCPUごとに、指定した保存先へ書き出される 例1: IPv4/TCP・宛先port 443 例2: VXLAN内のIPv4/TCP sudo bpf-ninja

    --mode xdp -i eth0 \ sudo bpf-ninja --mode xdp -i eth0 \ -w capture.pcap \ -w vxlan.pcap \ 'eth/ipv4/tcp[dport==443]' 'eth/ipv4/udp/vxlan/eth/ipv4/tcp' TCPの宛先portで絞る VXLANトンネルの内側のプロトコルで絞る --mode xdp -i eth0 eth0に、取得用のeBPFを接続する -w ファイル名 pcap保存先を指定する 17
  14. 余談: eBPF’26 workshop に採択されました • ACM SOSP に併設のワークショップで、bpf-ninjaのフィルタ用独自 DSL (Kunai)

    の論文が採択されました • 今⽇は eBPF 側ではなく、その後段でeventを受け取るGo側 (Reader 側) の設計 と性能改善を紹介します cf. https://ebpf.github.io/2026/papers.html 18
  15. BPF ring bufferで、カーネルからGoへeventを渡す NIC の受信 queue -> eBPFが取り出して ring bufferへ

    -> Go App の流れ Kernel 側 この場合のkey は5Tuple等で NIC Queueに振 り分けて仕事を 分割している NIC: RSS IP/portなどをhash Kernel 側 rx nic queue A CPU 0 で動く eBPF rx nic queue B CPU 1 で動く eBPF event 受け渡し用の queue User 側 BPF ring buffer eBPF eventを書く packet event event event Go App (reader goroutine) pcap file 19
  16. CPUごとにringを分けてみよう 書き込みが⼀つのringに集中しないよう、CPUごとに別のringを⽤意それぞれの ring の読み込みを 1 つの goroutine が担当 書き込みが集中するとロックやring溢れが起きやすいためこの形式を採用 Kernel

    側 ring = eventを渡す queue User 側 ring[0] CPU 0 で動くeBPF e e e Reader 0 goroutine packet pcap file ring[1] CPU 1 で動くeBPF packet e e e Reader 1 goroutine pcap file CPUの番号で、eBPFが書くringを選びます。 readerの番号は読むringとの対応であり、実⾏するCPUの指定ではありません。 20
  17. goroutine が ring を読む仕組み (1) 読了位置はGoのreaderが更新し、カーネルがそれを参照する Kernel 側 ring =

    eventを渡す queue User 側 ring[0] CPU 0 で動くeBPF packet 3 次に読む 値を参照 1 2 Reader 0 goroutine pcap file 読了 ring bufferの管理情報 読了位置 位置を更新 21
  18. goroutine が ring を読む仕組み (2) ringが空なら、Linuxのepollという仕組みで到着通知を待ちます。 readerはeventを読み続け、ringが空になったら次に event が到着するまで待つ 通知を受けたら、event本体をringから読みます。

    event がある ring = eventを渡す queue Kernel 側 User 側 ring[0] CPU 0 で動くeBPF e e pcap file Reader 0 goroutine e packet 次のeventを確認 event がない write CPU 0 で動くeBPF packet ring[0] 空っぽ! 通知 Reader 0 goroutine pcap file 22
  19. 実際に性能測定してみよう! 大量のパケットを送ってみて、Go 側でどのくらい capture できるかみてみよう 実験条件 • ring を CPU

    ごとに分ける構成を採用 • 16 CPU・16 ring・16 reader • Linux kernel 6.15 • パケット自体はプロトコルスタックまでパススルーさせる Kernel 側 ring = eventを渡す queue User側 ring[N] eBPF eventを書く e e e Reader goroutine packet ring: 16本 reader: 16本 pcap file 23
  20. 実際に性能測定してみよう! 結果: 全然性能が出なかった 大量のパケットを送ってみて、Go 側でどのくらい capture できるかみてみよう 入力 24 Mpps

    に対して 0.54 Mpps しか capture できず…… トホホ😭 実験条件 • ring を CPU ごとに分ける構成を採用 (Mpps = 毎秒100万packet) • 16 CPU・16 ring・16 reader • Linux kernel 6.15 24 Kernel 側 eBPF Mpps ⼊⼒ eventを書く ring = eventを渡す queue e packet NICへ⼊⼒したpacket数/秒 (Mpps) ring[N] 0.54 e Reader capture Mpps e goroutine User側 Goが読み出したpacket数/秒 pcap file reader: 16本 ring: 16本 (Mpps・中央値) 24
  21. ボトルネックはどこ? ⼊⼒とcaptureの差だけでは、どの段階で取り込めなかったかは分からない🤔 24 Mpps 入力 受信 packet ringに渡せた量 eBPF ringへ書き込む

    ring 0.54 Mpps Goでcaptureできた件数 Go側の処理 読む・数える 出⼒完了 出⼒保存 まず、eBPFがringへ書き、Goが読み出すところを確かめてみよう =>受け渡しを数えるため、測定用のカウンターを追加 25
  22. ring の前後にカウンターを追加して再測定しよう Kernel 側 (CPU Nのpath) eBPF ring[N] 測定ポイント ①

    eBPFの処理開始時 packet User(Go) 側 event event event 測定ポイント ② ring への書き込み時 終了後にGoから取得 ①, ②のカウントを格納 eventの流れ 測定ポイント④ bpf_ringbuf_reserveでリングの 空きをチェック(1/1024でサンプル) Go側の件数 実⾏中に加算 eBPF map PerCPUArray (測定用) reader[N]goroutine 測定ポイント③ capture時(ReadIntoでeventを読む) 終了後、CPU別に ①, ②, ③, ④ のカウント値を⽐較 測定値の更新・取得 26
  23. 結果: ring書き込みで失敗しまくっていた 処理開始とRing書き込みの差から 失敗がすごい多い😇 • eBPFの処理開始(取得対象): 116,395,873 • ring書き込み完了: 5,205,544

    • Goの読み出し: 5,205,544 • Go側での定期的な空き容量チェック: 全て失敗 ◦ • 読み込めた値は同じになってる 失敗1,024回に1回を確認して約5万回すべて失敗した 仮説: eBPF側はRingBufferを埋め切ることは成功してそう。 だとしたらGo側のReaderが空きの位置を返してあげれば、書き込み成功も増やせるのでは? • => その仮説を調べるため、次にGo側のCPUプロファイラを見てみる eBPF: Writer 空きがなければ書けない ring: 有限の領域 読了位置で再利用する Go: Reader 読み進めて空きを戻す 読了したという情報を返すのが遅いと書き込み側にも影響するのではないか? 27
  24. Go 側のプロファイリングをしてみよう! • ボトルネック特定のためにプロファイリングをしてみよう • Goの標準ライブラリには、アプリケーションのパフォーマンスやメモリ使用量 を計測・分析するための pprof が標準で組み込まれている ◦

    実⾏中の関数と、その呼び出し元をサンプルとして集め、 CPU profile として記録できる CPU を使っていた時間だけ記録されるので、 I/O 待ちなどの時間は記録されないことに注意 • go tool pprof を使えば framegraph の描画などもできる 28
  25. CPU profileを記録し、実⾏ファイルと残す 実⾏中の関数と呼び出し元の記録から、CPU時間の内訳を調べます Goプログラム 呼び出し元 実行中の関数 記録 go tool pprof:Top画⾯(上位3⾏)

    CPU profile cpu.prof runtime/pprofで サンプルを記録 保存profileの実画⾯ 送信側40.40 Mpps・kernel 6.15 ※次ページと同じ記録 記録する: bpf-ninjaの起動引数に追加 --cpuprofile cpu.prof 調べる: ブラウザで開き、VIEW → Top go tool pprof -http=localhost:8080 \ ./bpf-ninja cpu.prof ※採取にも負荷があるため、CPU時間の調査と処理率の⽐較は別のrunで⾏います。 29
  26. プロファイリング結果を見てみよう go tool pprof -top bpf-ninja.bin cpu.prof • • profile全体のCPU時間:

    12.53 s flatはその関数⾃⾝にかかったCPU時間、cumはその呼び出し先も含むCPU時間 関数名(flat上位5⾏) flat flat% cum cum% runtime.futex 7.44 s 59.38% 7.44 s 59.38% runtime.asyncPreempt 2.32 s 18.52% 2.32 s 18.52% runtime.memmove 2.18 s 17.40% 2.18 s 17.40% internal/runtime/syscall.Syscall6 0.21 s 1.68% 0.21 s 1.68% …/capture.(*Reader).RunShards.func1 0.11 s 0.88% 4.97 s 39.66% 5⾏⽬の処理は各ringを読むgoroutine。cum が妙に大きい……? 30
  27. readerの経路では何に時間がかかっている? reader の cum(4.97 s)の内訳を⾒てみよう ↓ -focus optionで、Reader.RunShardsを含むsampleだけを掘り下げられる go tool

    pprof -top -focus='Reader.*RunShards' \ xdp-ninja.bin cpu.prof 絞った範囲で⽬⽴つ関数 flat flat% runtime.asyncPreempt 2.32 s 18.52% runtime.memmove 2.18 s 17.40% asyncPreempt: goroutineを⾮同期に⼀時中断する仕組み「⾮同期プリエンプション」の処理 => 仮説1: ⾮同期プリエンプションが、読み出せる量に影響しているのでないか? memmove: メモリのcopy のための処理 => 仮説2: copyを減らせば性能が上がるのでは? 31
  28. Flame Graph も見てみよう(pprof) 前ページと同じreader経路を、pprofのFlame Graphで表⽰したもの 1: readerのループ Reader.Runshard 3:一時中断の処理 runtime.asyncPreempt

    2: ringの読み出し ringbuf.Reader.ReadInfo 4: メモリのcopy runtime.memmove 確かに大きく呼び出しリソースを使っていることがわかる profile全体のasyncPreempt(flat): 2.32 s / 12.53 s = 18.5% profile全体のmemmove(flat): 2.18 s / 12.53 s = 17.4% 32
  29. 書く側と読む側は、どのCPUで動くか ここまでは、readerの処理内容を⾒てきました。構成図へ戻ってみます。 eBPFはCPUごとのringへ書きますが、goroutineを割り当てるだけでは読むCPUは 決まりません。 Kernel 側 ring = eventを渡す queue

    User 側 ring[0] CPU 0 で動くeBPF e e e packet Reader 0 goroutine CPU 0 pcap file ring[1] CPU 1 で動くeBPF packet e e e Reader 1 goroutine CPU1 pcap file 仮説3: 同じringを書く側と読む側のCPU配置分離で、 読み出せる量が変わるのでは? 34
  30. 第2部のまとめ ボトルネックは Go の処理にあったのかも? Goで書かれたreaderの処理を速くできるかもしれない 3 つの仮説 ここまでの⼿掛かり ⽴てた仮説 次に⽐べること

    reader経路のasyncPreempt ⾮同期プリエンプションが、 読み出せる量に影響するのでは。 ⾮同期プリエンプションの 設定の⽐較(on / off) copyと読了位置の更新 ⼀件を読む仕事を減らせば、 処理量が増えるのでは。 読み出し実装の変更前 / 後 同じringを使うeBPFとreader readerを置くCPUで、 読み出せる量が変わるのでは。 配置を任せる場合と 指定する場合 まずは⾮同期プリエンプション設定の⽐較から。設定の役割を理解するため、 Goがgoroutineをどう実⾏・中断するかを⾒てみましょう! 35
  31. 三つの仮説を検証してみよう 1. ⾮同期プリエンプション設定のon / off 2. Readerの読み出し実装で Copy を減らす工夫をする前 /

    後 3. goroutine が利用する CPU core を分離する場合 まずは、非同期プリエンプション設定の比較から。 その前に、この中断が何のためにあるのかを見ます。 38
  32. GoSchedulerは動かすgoroutineを切り替える 一つの処理が待つ間も、ほかの処理を進めるための仕組みを見ます。 Gはgoroutine、MはOS thread。Go schedulerが、Mで実⾏するGを選びます。 実⾏できる G 選んだ G を

    M 上で実⾏ M0 G0 G1 G2 G3 Go runtime scheduler 実⾏する G を選ぶ OS thread M1 OS thread G0 Goroutine を実行 G1 Goroutine を実行 左は選択前、右は実⾏中の例。 39
  33. Goコードを同時に動かせるのは、Pの数まで goroutineのGoコードを動かすMには、Pが⼀つ必要です。 Pは実⾏待ちqueueやメモリ管理の状態を持つ構造体で、別のMへ引き継げます。 例:GOMAXPROCS = 2(Pが⼆つ) M0(OS thread) M1 (OS

    thread) P0 G0 実⾏待ちのGのqueue メモリ管理の状態など G0を実行 P1 G1 実⾏待ちのGのqueue メモリ管理の状態など G1を実行 P0はCPU 0を意味しない。Mの実⾏CPUはOSが選ぶ。 Pの数がGOMAXPROCS。図ではPが⼆つなので、同時に実⾏できるGoコードは最⼤⼆つ。 OS threadの総数や、実⾏するCPU番号を指定するものではありません。 40
  34. threadごと待っても、別のGを動かせる epollのsyscall(カーネルへの呼び出し)で、readerのG1とM1が待ちます。 必要に応じて同じP0を別のM2へ引き継ぎ、G2を実⾏できます。 待つ前 M1: readerを実⾏ G1 ringが空 → epollで待つ

    待つ間 待つ G1 readerのG1とM1が通知を待つ P0 M2: 別のGを実行 G2 P0 同じP0をM2へ引き継ぐ sysmon 👀 Go runtimeの監視処理 必要に応じて、P0の引き継 ぎを判断 必要に応じて、 P0の引き継ぎを判断 10ms程度続行してたら中断要求をする 42
  35. 性能試験: 非同期プリエンプションの on/off • 比較対象 ◦ A (on): ⾮同期プリエンプションを有効にする ◦

    B (off): asyncpreemptoff=1 で無効にする • 測定条件: 同じworkloadで、各5回・1回60秒 • ⽐較する値: Go側で処理した packet数/秒 46
  36. ⾮同期プリエンプション設定の比較で分かったこと • ⾮同期プリエンプションは、off にした方が性能が向上した • 負荷が高いケースでは epoll ではなく busy-poll による読み込みが発生する

    が、これを evict する仕組みは、今回のケースでは逆効果であった ◦ 大量のデータを読み出して常に CPU を稼働させるようなタスクでは、 割り込みの仕組みは性能低下の原因になりうる ◦ ただ、GC等まで含めた評価は課題として考えれるかもしれない と思ってます。 → が、常に読み続けるパターンが支配的なので、 今回のケース (bpf-ninja) ではデフォルトではoffに設定した 48
  37. Copy の挙動をみてみよう pprof で⾒つけた memmove は何を Copy する処理なのか? → ring

    の byte 列を別 buffer へ copy していた! pprofの呼び出しパス(抜粋) readRecord 内部の copy 処理 Run Shards.func1 各ringを読むgoroutine copy(rec.RawSample, rr.ring[start:]) cf. https://github.com/cilium/ebpf/blob/fd33a781ea9ebf9d1bff748707793deccc412c05/ringbuf/ring.go#L96 ringbuf.Reader.ReadInto mmapしたring copy eventのbyte列 RawSample(別buffer) 同じeventのbyte列 ringReader.readRecord runtime.memmove buffer への copy を bypass することにより copy 回数を減らすことができる 呼び出し元が ReadIntoの結果を 受け取って処理 50
  38. 読了位置の更新回数にも着目 書く側(eBPF)に「ここまで読んだ」と伝える共有の値 読了位置 を 1件ごとではなく、複数件の処理後に1回更新するようにする。 変更前: 1件取り出すたびに更新 変更後: 複数件の処理後に1回 Aを処理

    Bを処理 Cを処理 Aを処理 「Aまで読んだ」 を記録 「Bまで読んだ」 を記録 「Cまで読んだ」 を記録 共有値への書き込み: 3回 Bを処理 Cを処理 「Cまで読んだ」と1回だけ記録 共有値への書き込み: 1回 共有する管理情報への書き込み回数を減らすことができた! 51
  39. ⼀件を読む仕事を減らしたので、次は実⾏する場所を考える • Ring はどの Core からも読めるので、goroutine はどこの CPU Core でも実行可能

    • eBPF が動く CPU Core と、その ring を読む goroutine が動く CPU Core は 同一の方がいいのか? それとも異なる CPU Core で動いた方がいいのか? Writer Reader CPU N 書く 読む 実⾏CPUは 未指定 Ring N eBPF 共有メモリ Reader N reader N の N は「担当するring」の番号 54
  40. 同じCPUへ寄せるか、別のCPUへ分けるか 共有データの扱いとCPU時間の取り合いに注意が必要 同じCPUへ寄せる / same 別のCPUへ分ける / split CPU N

    CPU K CPU N Reader Writer/eBPF Ring N Reader Writer/eBPF Ring N 󰢐 CPU間で共有データの受け渡しを減らせる 󰢐 CPU 時間の取り合いにならない 󰢄 書く・読む処理はCPU時間の取り合いになる 󰢄 CPU 間での共有データ受け渡しが多くなる どちらがいいか実際に計測して確認してみよう! ※今回はcacheの寄与は未計測。ちなみに別の論理CPUでもSMTでは実⾏資源を共有自体はする。 55
  41. 実験: same / split-CPUの性能比較 同じNUMA node内の16物理コアで⽐較 先ほどまでの改善(プリエンプション off・fast reader 利用)を入れた上で

    same / split の設定を加え比較を実施した D same-CPU: 16Core E split-CPU: 8 + 8 Core Reader Writer/eBPF Ring [16] CPU 8-15 CPU 0-7 CPU 0-15 Reader Writer/eBPF Ring [8] 受信の並列度を減らす代わりに、readerへ別の物理コアとして占有 割り当てをするのが変更ポイント 56
  42. ① LockOSThreadで、reader Gを現在のMへ結び付ける • まず G (reader の goroutine) と

    M (OS thread) の紐付け を固定 ◦ LockOSThread を用いて、reader G を現在の M へ結び付ける Go:どのMでGを動かすか OS:どのCPUでMを動かすか ① LockOSThread OSに任せる reader G 現在のM 実行CPUは 未指定 // 専用reader goroutineの中で呼ぶ runtime.LockOSThread() =>次に、このMが実⾏できるCPUを指定します。 57
  43. ② affinityで、そのMが使えるCPUを指定する • 次に、M と特定の CPU の紐付けを固定 ◦ affinity 指定により、現在のMが実⾏できるCPUをNだけに制限できる

    reader G Go:どのMでGを動かすか OS:どのCPUでMを動かすか ① LockOSThread ② CPU affinity 現在のM 指定したCPU N // LockOSThread後、同じ専用reader goroutineで続ける var set unix.CPUSet set.Set(cpu) if err := unix.SchedSetaffinity(0, &set); err != nil { return err } return readRing(ctx, ring) 58
  44. 結果: same / split-CPUの性能比較 異なる CPU を利用させた方が性能が向上した! D (same, eBPF

    も Go も同一の CPU) → E (split, Go 専用の CPU 割り当て利用) の 中央値の⽐は約1.58倍。2回すべてで改善した🎉 D E 59
  45. Goroutineにお任せで再実験! 構成 受信IRQのCPU readerの実⾏可能CPU 16 IRQ・auto配置(ディフォルト) 0–15 個別固定なし:0–15 16 IRQ・same

    固定 0–15 0–15 8 IRQ・split 固定 0–7 8-15 8 IRQ・auto配置 (New!) 0–7 個別固定なし:0–15 8C+8Cのケースでautoにやってみた 61
  46. 第3部のまとめ それぞれの改善で最終的にGo側で数えたMppsがどう変わったかを整理します ⽐較項⽬ 変えたもの Go で捌けたパケット数 (Mpps) A: (デフォルトの構成) 通常reader・プリエンプションon・CPU指定

    0.54 Mpps なし B: goroutine の挙動変更 A->B: ⾮同期プリエンプションをoffへ 1.67 Mpps C: ⼀件あたりの読み出し 処理を工夫 B->C: copy 処理を減らした専用のreader利 用 2.99 Mpps E: CPU の割り当てを変更 C->E: R/Wそれぞれ専用の CPU core 割り当 て 4.287 Mpps 測定に基づいた工夫により性能が8倍ほど向上!🎉 より使い物にするために様々なチューニングは続く... <=to be continued= 64
  47. 性能が足りないとき、どこを調べるか ストリーム処理の実装をする際、まずは Go と OS に任せてみて、 必要な性能に届かなければ以下の 4 つを検討 •

    仕事の分担: データに順序依存性や、入力の偏りはあるか ◦ • 受け渡し相手: 詰まった queue の前後に測定ポイントを入れてみる ◦ • 例えば: queueが詰まるのは、受信して処理する側が遅いから? 処理内容: CPU bound な処理時間と I/O bound 処理の待ち時間を分けて考える ◦ • 例えば: 同じ series は同じ queue という制約はあるか。queue ごとの⼊⼒に偏りはないか? 例えば: 送信が遅いとき、圧縮でCPUを使うのか、応答を待つのか? 実行場所: 共有データやCPU時間の取り合いを見る ◦ 例えば: queueの両側の処理を、同じCPUコアと別コアのどちらで動かすか? 66
  48. 復習: Prometheus の Remote Write の例 queue が詰まっているとしたら原因は? 送信側: Prometheus(Go)

    送信goroutine0 queue 0 batch 入力 WALから読む 変換・圧縮 HTTP送信 送信goroutine1 queue 1 batch queue への入力が queue の 多すぎる 本数が少ない 変換・圧縮 送信先 保存 HTTP送信 queue から取り出したものを 処理する速度が遅い 67
  49. そもそもqueueの数が足りない例 (Prometheus) 並列数が足りず、スループットが落ちて結果としてqueue が詰まってしまっている状態 送信側: Prometheus(Go) goroutine0 queue 0 入力

    WALから読む 満杯 変換・圧縮 HTTP送信 goroutine1 queue 1 満杯 変換・圧縮 送信先 保存 HTTP送信 実際、Prometheus には、スループット低下を検知すると自動で queue と それに対応する goroutine を増やす リシャーディング という仕組みがある https://prometheus.io/docs/practices/remote_write/#remote-write-characteristics 68
  50. 仕事の分担に原因がある例 (Prometheus) queue への分配に偏りが生じている可能性がある 例: データが queue 0 に集中している場合 送信側:

    Prometheus(Go) goroutine0 queue 0 入力 WALから読む 満杯 変換・圧縮 HTTP送信 goroutine1 queue 1 空きあり 変換・圧縮 送信先 保存 HTTP送信 Prometheus は実際、queue が 1 本でも詰まった場合は metrics 欠損を防ぐため全体をブロックする 仕様を明記している。影響範囲が大きくなりうるので注意が必要 https://prometheus.io/docs/practices/remote_write/#remote-write-characteristics 69
  51. 受け渡し相⼿の状態に原因がある例 (Prometheus) goroutine が呼び出しているコンポーネントの応答が遅いと、goroutine の channel queue にも 滞留が⽣じてしまう 例:

    goroutine が metrics を送信する先の応答が遅い場合 送信側: Prometheus(Go) goroutine0 queue 0 入力 WALから読む 満杯 変換・圧縮 HTTP送信 goroutine1 queue 1 満杯 変換・圧縮 送信先 応答が遅い HTTP送信 遅れが送信先からqueue側へ伝搬していく 送信先をどうにかするのは難しいので、必要に応じて入力を減らす対応も考慮の必要あり https://grafana.com/blog/how-to-troubleshoot-remote-write-issues-in-prometheus/ 70
  52. 処理内容を調べる例:Prometheus 変換・圧縮でCPUを使っているのか、送信先の応答を待っているのか。 例:CPU時間と応答までの時間を調べる 送信側: Prometheus(Go) 送信goroutine0 queue 0 batch 入力

    WALから読む HTTP送信 送信先 保存 送信goroutine1 queue 1 batch ⼊⼒・未送信数 変換・圧縮 変換・圧縮 HTTP送信 CPU profileで調べる 送信から応答 までを測る CPU profileは関数と呼び出し元を⾒る。CPUを使わない待機時間は含まれません。 ⼊⼒と送信件数、送る側のCPU、回線と送信先の応答を調べてみます 71
  53. 実⾏場所を調べる例:TCP proxy • • Writer と Reader を同一 or 異なる

    CPU Core で動かすべきか 同じ⼊⼒で処理量・応答時間・両 thread の CPU使⽤量を比較することで最適な実行場所がわかる • liburing / io_uring_sqpoll(7)のmanにも「CPUに余裕がないと、polling threadとアプリがCPUを 取り合い、性能が落ち得る」という話が書いてある The polling thread consumes CPU. If the system is already CPU-bound, adding a polling thread may compete with the application for CPU resources, reducing overall performance. • 同じ論理CPUに寄せる/ same CPU N 別の論理CPUへ分ける / split CPU K CPU N proxy thread 依頼を書く SQPOLL Thread SQ: Submission Queue 共有メモリ proxy thread 依頼を書く SQPOLL Thread SQ: Submission Queue 共有メモリ 󰢐 同じCPUから共有SQを読み書き 󰢐 CPU 時間の奪い合いにならない 󰢄 CPU時間の取り合いになる 󰢄 SQの受け渡しがコアをまたぐ 72
  54. まとめ: Goで⾼速なストリーム処理を作るためには • goroutineとchannelを有効活用 ◦ 独⽴に進められる仕事についてはgoroutineへ分担させ、ストリーム処理を 組み⽴てよう • データの特性を考えて分担する単位を決める ◦

    • 順序依存性があるなど、並列に扱えないデータは、同じgoroutineへ送る まずGoとOSに任せてみて、性能が⾜りなければ計測に基づき改善する ◦ データの分担・受け渡し相⼿・処理内容・実⾏場所に問題がないか調べる ◦ 変更を入れてみたら、処理量・遅延・CPU使⽤量などへの影響を変更前後で⽐べ てみよう 74