Slide 1

Slide 1 text

When Goroutines Are Not Enough: Runtime Locality in High-Throughput Go 2026/09/11 Takeru Hayasaka GoConference2026

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

この発表のモチベーション ● 目的: Goで⾼スループットなデータ処理を実現したい ○ ● e.g. メトリクスデータやパケットの加工・集計・取り込み 課題: データと処理をどう分け、goroutineにどう担当させるか。 その構成で性能が足りないとき、何を調べ、どこを変えるか。 ○ 例えばgoroutineへ仕事を分けても、実⾏するCPUまでは決まりません。 具体的にはCPUバウンドだとGo任せだと足りない可能性あります。 このような場合に使える改善手法をボトルネック分析と合わせて紹介します ○ ● 改善提案例 ■ スケジューラ: 割り込み禁止、CPUコア固定 ■ ほか: 不要なコピー削減、バッチ処理 この発表で伝えること: 設計と性能改善に使える手がかり ○ 処理をどんな単位に分け、goroutineにどう担当させるか? ○ GoやOSに任せる範囲をどう決めるか? ○ CPUアフィニティを変えるとき、何を計測して効果を判断するか 3

Slide 4

Slide 4 text

Agenda 1. 複数のgoroutineへ仕事を分担させる設計 a. 独立に進められる単位を考え、データと処理を分ける。 2. 実例:packet captureツールの設計と性能調査 a. ツールの構造と、pprofで調べたCPU時間の使い道。 3. 実例:packet captureツールの性能改善 a. 中断の設定・読み出しの実装・CPU配置を変えて比べる。 4. ストリーム処理への応用 a. 処理の偏りや待機の仕方、CPU配置をどう調べるか。 4

Slide 5

Slide 5 text

1. 複数のgoroutineへ仕事を分担させる設計 5

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

複数の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

Slide 9

Slide 9 text

複数の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

Slide 10

Slide 10 text

処理が独立していない場合はどうなる……? ファイルの計算は独立だったため、一件ずつ別の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

Slide 11

Slide 11 text

Prometheusの例:同じseriesは順序を保って送る Prometheus: remote write 収集したmetricsのサンプルを、外部の保存先へ送る機能 受信側が順序の逆転を受け入れるとは限らないので、同じseriesのサンプルを時刻順に 処理する必要がある 例:http_requests_total(リクエスト数) ラベルの組で系列を識別、サンプル:時刻と値の組 series A job="api" instance="a" series B job="api" instance="b" cf. Prometheus Remote-Write 1.0 Ordering 時刻: 12:00 時刻順 値: 100 時刻: 12:00 値: 200 時刻: 12:01 値: 120 時刻順 別のseriesとは 並行に送れる 時刻: 12:01 値: 200 11

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

第1章のまとめ goroutine を用いたストリーム処理について…… 一つの処理段階を分担する方法には 2 種類あり、 ● 独⽴した仕事:共有queueを複数workerで読む ○ 受け取ったworkerがその⼀件を処理する ○ ファイルの checksum 計算の例 ● 順序や状態をまとめたい仕事:keyごとに送り先を決める ○ key (seriesのように、まとめるデータを識別する値) に基づき、 同じkeyは同じqueueへ送り、⼀つのgoroutineがそれを処理する ○ Prometheus の remote write の例では、送信順を保ちながら処理することが 可能になった 14

Slide 15

Slide 15 text

2. 実例:packet captureツールの設計 と性能調査 15

Slide 16

Slide 16 text

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」と呼びます cf. https://github.com/takehaya/bpf-ninja logo license: eBPF and the eBPF logo are trademarks of The Linux Foundation. The Go Logo is a trademark of Google LLC. 16

Slide 17

Slide 17 text

パケットキャプチャ設定の様子 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

Slide 18

Slide 18 text

余談: eBPF’26 workshop に採択されました ● ACM SOSP に併設のワークショップで、bpf-ninjaのフィルタ用独自 DSL (Kunai) の論文が採択されました ● 今⽇は eBPF 側ではなく、その後段でeventを受け取るGo側 (Reader 側) の設計 と性能改善を紹介します cf. https://ebpf.github.io/2026/papers.html 18

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

実際に性能測定してみよう! 大量のパケットを送ってみて、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

Slide 24

Slide 24 text

実際に性能測定してみよう! 結果: 全然性能が出なかった 大量のパケットを送ってみて、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

Slide 25

Slide 25 text

ボトルネックはどこ? ⼊⼒とcaptureの差だけでは、どの段階で取り込めなかったかは分からない🤔 24 Mpps 入力 受信 packet ringに渡せた量 eBPF ringへ書き込む ring 0.54 Mpps Goでcaptureできた件数 Go側の処理 読む・数える 出⼒完了 出⼒保存 まず、eBPFがringへ書き、Goが読み出すところを確かめてみよう =>受け渡しを数えるため、測定用のカウンターを追加 25

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

結果: 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

Slide 28

Slide 28 text

Go 側のプロファイリングをしてみよう! ● ボトルネック特定のためにプロファイリングをしてみよう ● Goの標準ライブラリには、アプリケーションのパフォーマンスやメモリ使用量 を計測・分析するための pprof が標準で組み込まれている ○ 実⾏中の関数と、その呼び出し元をサンプルとして集め、 CPU profile として記録できる CPU を使っていた時間だけ記録されるので、 I/O 待ちなどの時間は記録されないことに注意 ● go tool pprof を使えば framegraph の描画などもできる 28

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

プロファイリング結果を見てみよう 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

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

Flame Graph も見てみよう(perf) Linux側のprofileも撮ってみたと殆どが受信処理で埋まってた net_rx_action 95%がLinuxの受信処理 xdp-ninja(bpf-ninja) 4.5%だけしか使えてない もしかして、受信処理が忙しくて、Goのreaderを動かす余裕がない? (packet処理がGoの足を引っ張ってる...?) 33

Slide 34

Slide 34 text

書く側と読む側は、どの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

Slide 35

Slide 35 text

第2部のまとめ ボトルネックは Go の処理にあったのかも? Goで書かれたreaderの処理を速くできるかもしれない 3 つの仮説 ここまでの⼿掛かり ⽴てた仮説 次に⽐べること reader経路のasyncPreempt ⾮同期プリエンプションが、 読み出せる量に影響するのでは。 ⾮同期プリエンプションの 設定の⽐較(on / off) copyと読了位置の更新 ⼀件を読む仕事を減らせば、 処理量が増えるのでは。 読み出し実装の変更前 / 後 同じringを使うeBPFとreader readerを置くCPUで、 読み出せる量が変わるのでは。 配置を任せる場合と 指定する場合 まずは⾮同期プリエンプション設定の⽐較から。設定の役割を理解するため、 Goがgoroutineをどう実⾏・中断するかを⾒てみましょう! 35

Slide 36

Slide 36 text

3. 実例:packet captureツールの性能改善 36

Slide 37

Slide 37 text

三つの仮説を検証してみよう 1. ⾮同期プリエンプション設定のon / off 2. Readerの読み出し実装で Copy を減らす工夫をする前 / 後 3. goroutine が利用する CPU core を分離する場合 37

Slide 38

Slide 38 text

三つの仮説を検証してみよう 1. ⾮同期プリエンプション設定のon / off 2. Readerの読み出し実装で Copy を減らす工夫をする前 / 後 3. goroutine が利用する CPU core を分離する場合 まずは、非同期プリエンプション設定の比較から。 その前に、この中断が何のためにあるのかを見ます。 38

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

channel待ちでは、同じthreadで別のGを動かせる 受信できるdataがないG1を待機させ、同じM1とP0で別のG2を実⾏します。 待つのはG1。M1とP0は、次の仕事へ進めます。 待つ間 待つ前 M1 OS thread G1 別のGを実行 待つ G1 channel やnetwork I/O の場合 G1は待機。待ちが終わると 実行候補に戻る P0 M1 OS thread 別のGを実行 G2 P0 41

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

低負荷では、ringを読み切ってから待つ ● ReadInto は、読めるeventがあれば1件を返し、なければ到着を待つ ● つまり、⼊⼒が少なければringを読み切り、次の到着までepollで待ちます 低負荷:読み終えるとringが空になる ringの状態 empty epoll_wait eventが到着 read readerとMが待つ 読み出しへ戻る eventを処理 次の読み出しへ 43

Slide 44

Slide 44 text

⾼負荷でringが空にならないと、到着を待たずに読み続ける ● ReadInto は次のeventがあれば到着を待たずに返るため、busy-pollをして 読み出しを続けられます。 ● しかしPの数には上限があり、常にその仕事が行うと他のGの実行機会が 与えられません... ⾼負荷:読み終えてもringにeventが残る ringの状態 readを続けられる epoll_wait eventが到着 read readerとMが待つ 読み出しへ戻る eventを処理 次の読み出しへ 44

Slide 45

Slide 45 text

⻑く⾛るGには、sysmonが中断を要求する sysmonは、Pを持たない専⽤threadで実⾏を監視します。 待たずに⾛るGにも中断を要求し、ほかのGを進める機会を作ります。 1. sysmonが、対象のMへSIGURGというsignalを送る。 2. signalを受けたhandlerが、安全に中断できるか確認する。 条件を満たせば、戻り先をasyncPreemptへ変更する。 3. handlerから戻ったあと、asyncPreemptが実⾏状態を保存する。 a. 実行状態を保存しschedulerへ入る 4. schedulerへ⼊り、次に実⾏するGを選ぶ。 a. 次に実行するGを選ぶ 45

Slide 46

Slide 46 text

性能試験: 非同期プリエンプションの on/off ● 比較対象 ○ A (on): ⾮同期プリエンプションを有効にする ○ B (off): asyncpreemptoff=1 で無効にする ● 測定条件: 同じworkloadで、各5回・1回60秒 ● ⽐較する値: Go側で処理した packet数/秒 46

Slide 47

Slide 47 text

結果: ⾮同期プリエンプションの on/off ⾮同期プリエンプションを切ったほうが性能が出る! A (on) → B (off) の中央値の⽐は約3.27倍。5回すべてで改善した🎉 A B 47

Slide 48

Slide 48 text

⾮同期プリエンプション設定の比較で分かったこと ● ⾮同期プリエンプションは、off にした方が性能が向上した ● 負荷が高いケースでは epoll ではなく busy-poll による読み込みが発生する が、これを evict する仕組みは、今回のケースでは逆効果であった ○ 大量のデータを読み出して常に CPU を稼働させるようなタスクでは、 割り込みの仕組みは性能低下の原因になりうる ○ ただ、GC等まで含めた評価は課題として考えれるかもしれない と思ってます。 → が、常に読み続けるパターンが支配的なので、 今回のケース (bpf-ninja) ではデフォルトではoffに設定した 48

Slide 49

Slide 49 text

三つの仮説を検証してみよう 1. ⾮同期プリエンプション設定のon / off 2. Readerの読み出し実装で Copy を減らす工夫をする前 / 後 3. goroutine が利用する CPU core を分離する場合 49

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

読了位置の更新回数にも着目 書く側(eBPF)に「ここまで読んだ」と伝える共有の値 読了位置 を 1件ごとではなく、複数件の処理後に1回更新するようにする。 変更前: 1件取り出すたびに更新 変更後: 複数件の処理後に1回 Aを処理 Bを処理 Cを処理 Aを処理 「Aまで読んだ」 を記録 「Bまで読んだ」 を記録 「Cまで読んだ」 を記録 共有値への書き込み: 3回 Bを処理 Cを処理 「Cまで読んだ」と1回だけ記録 共有値への書き込み: 1回 共有する管理情報への書き込み回数を減らすことができた! 51

Slide 52

Slide 52 text

性能試験結果: Reader 読み出し実装の変更前後 専⽤readerを使ったほうが性能が出る! B (デフォルト) → C (専用) の中央値の⽐は約1.79倍。5回すべてで改善した🎉 B C 52

Slide 53

Slide 53 text

三つの仮説を検証してみよう 1. ⾮同期プリエンプション設定のon / off 2. Readerの読み出し実装で Copy を減らす工夫をする前 / 後 3. goroutine が利用する CPU core を分離する場合 53

Slide 54

Slide 54 text

⼀件を読む仕事を減らしたので、次は実⾏する場所を考える ● 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

Slide 55

Slide 55 text

同じ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

Slide 56

Slide 56 text

実験: 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

Slide 57

Slide 57 text

① 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

Slide 58

Slide 58 text

② 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

Slide 59

Slide 59 text

結果: same / split-CPUの性能比較 異なる CPU を利用させた方が性能が向上した! D (same, eBPF も Go も同一の CPU) → E (split, Go 専用の CPU 割り当て利用) の 中央値の⽐は約1.58倍。2回すべてで改善した🎉 D E 59

Slide 60

Slide 60 text

ここで疑問.... 通信割り込みを手で寄せないといけない <- 分かる GoにおいてもCPUの選択をして動かすことができる <- 分かる Readerの使うCPUを手で選択しないと行けない←本当に?? GoroutineってSchedulerというやつがいましたよね・・・? もしかしたら余ってるCPUを Goroutineはいい感じに使ってくれるのでは!? チューニング 手抜きしたいな... 60

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

8コア受信の構成は、固定なしでも約4.3 Mpps ギリ明示的なSplitの方がいい感じだけど、ほとんどAutoでも同じぐらいの性能 であることを確認した。 (よく考えればそうかもだが)Goroutineってすげー F E 62

Slide 63

Slide 63 text

CPUの利用時間比率から推測してみる ほとんどSplitと同じで、Autoでもいい感じにできてそうなことわかった! => 利用するCPUを分離して使うことは有効で、カーネルなどが相手の場合は、 Goの方を変更しなくても、カーネル側のCPUコアを寄せればうまいことRuntime がやってくれそう! 63

Slide 64

Slide 64 text

第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

Slide 65

Slide 65 text

4. ストリーム処理への応用 65

Slide 66

Slide 66 text

性能が足りないとき、どこを調べるか ストリーム処理の実装をする際、まずは Go と OS に任せてみて、 必要な性能に届かなければ以下の 4 つを検討 ● 仕事の分担: データに順序依存性や、入力の偏りはあるか ○ ● 受け渡し相手: 詰まった queue の前後に測定ポイントを入れてみる ○ ● 例えば: queueが詰まるのは、受信して処理する側が遅いから? 処理内容: CPU bound な処理時間と I/O bound 処理の待ち時間を分けて考える ○ ● 例えば: 同じ series は同じ queue という制約はあるか。queue ごとの⼊⼒に偏りはないか? 例えば: 送信が遅いとき、圧縮でCPUを使うのか、応答を待つのか? 実行場所: 共有データやCPU時間の取り合いを見る ○ 例えば: queueの両側の処理を、同じCPUコアと別コアのどちらで動かすか? 66

Slide 67

Slide 67 text

復習: Prometheus の Remote Write の例 queue が詰まっているとしたら原因は? 送信側: Prometheus(Go) 送信goroutine0 queue 0 batch 入力 WALから読む 変換・圧縮 HTTP送信 送信goroutine1 queue 1 batch queue への入力が queue の 多すぎる 本数が少ない 変換・圧縮 送信先 保存 HTTP送信 queue から取り出したものを 処理する速度が遅い 67

Slide 68

Slide 68 text

そもそも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

Slide 69

Slide 69 text

仕事の分担に原因がある例 (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

Slide 70

Slide 70 text

受け渡し相⼿の状態に原因がある例 (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

Slide 71

Slide 71 text

処理内容を調べる例:Prometheus 変換・圧縮でCPUを使っているのか、送信先の応答を待っているのか。 例:CPU時間と応答までの時間を調べる 送信側: Prometheus(Go) 送信goroutine0 queue 0 batch 入力 WALから読む HTTP送信 送信先 保存 送信goroutine1 queue 1 batch ⼊⼒・未送信数 変換・圧縮 変換・圧縮 HTTP送信 CPU profileで調べる 送信から応答 までを測る CPU profileは関数と呼び出し元を⾒る。CPUを使わない待機時間は含まれません。 ⼊⼒と送信件数、送る側のCPU、回線と送信先の応答を調べてみます 71

Slide 72

Slide 72 text

実⾏場所を調べる例: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

Slide 73

Slide 73 text

第4部のまとめ 遅れている箇所とその前後を描き、調べる対象を決めて検証するのがポイントです 例えば、こう分かったら 次に検討すること ⼀部のqueueに⼊⼒が偏る 順序等の制約を守らなければならない場合は、 分担方法を⾒直す goroutineが呼び出している相手の 応答が遅い 相⼿の処理能⼒と、送る量・並列数を調べる copyや変換のCPU時間が⼤きい ⼀件の仕事を減らす/batchでまとめる 同じdataを複数の処理で使う 実⾏するCPUを調べ、同じ Core で実行するのと 異なる Core で分離するのとどちらが良いか調べる 73

Slide 74

Slide 74 text

まとめ: Goで⾼速なストリーム処理を作るためには ● goroutineとchannelを有効活用 ○ 独⽴に進められる仕事についてはgoroutineへ分担させ、ストリーム処理を 組み⽴てよう ● データの特性を考えて分担する単位を決める ○ ● 順序依存性があるなど、並列に扱えないデータは、同じgoroutineへ送る まずGoとOSに任せてみて、性能が⾜りなければ計測に基づき改善する ○ データの分担・受け渡し相⼿・処理内容・実⾏場所に問題がないか調べる ○ 変更を入れてみたら、処理量・遅延・CPU使⽤量などへの影響を変更前後で⽐べ てみよう 74

Slide 75

Slide 75 text

Enabling a Connected Future. 75