🔩 ねじき教室 Go と Web の教室

増やすほど遅くなることがある

読了目安 約5分

リソースを増やしすぎると容量が落ちる現象を、競合と協調の 2 つの係数で表し、線形にスケールするとは何かに答えます。

30秒でつかむ 台数とともに、競合と協調も増える
台数とともに、競合と協調も増える台数を増やすほど共有部分の競合とノード間の協調が増え、やがて容量が下がります。1台数を増やす並列部分が増える2競合同じ資源を待つ3協調組合せが増える
  1. 台数を増やす 並列部分が増える
  2. 競合 同じ資源を待つ
  3. 協調 組合せが増える
この章の目次

ワーカー数やコネクション数を増やしていくと、あるところから逆に遅くなります。 これは調整の失敗ではなく、増やすこと自体に費用があるからです。

2 種類の費用

リソースを pp 個に増やしたときの相対容量は、2 つの係数でよく説明できます。

C(p)=p1+α(p1)+βp(p1) C(p) = \frac{p}{1 + \alpha(p-1) + \beta p(p-1)}
  • C(p)C(p) :前章と同じ相対容量。1 個のときの何倍か
  • α\alpha競合の係数。共有リソースを同時に使えず、待ち合わせる費用
  • β\beta協調の係数。参加者どうしで状態を一致させる費用

どちらも 0 なら C(p)=pC(p) = p で、増やした数だけ容量が増えます。 ここでは 0α10 \leq \alpha \leq 1β0\beta \geq 0 の範囲を考えます。

この 2 つは、曲線の形が違います。

  • α\alpha だけがあるとき、容量は増えなくなるが下がりはしない
  • β\beta があると、あるところで頂点を越えて下がっていく

この式は USL(Universal Scalability Law)と呼ばれます。 競合の項はおおむね pp に比例しますが、協調の項は参加者の組み合わせが増えるため p(p1)p(p-1) に比例します。 0α<10 \leq \alpha < 1β>0\beta > 0 のとき、容量が頂点を打つ個数を pp^{*} と書くと、こうなります。

p=1αβ p^{*} = \sqrt{\frac{1-\alpha}{\beta}}
Go
package main

import "fmt"

func usl(p, alpha, beta float64) float64 {
	return p / (1 + alpha*(p-1) + beta*p*(p-1))
}

func main() {
	fmt.Println("  p    理想   競合あり   競合+協調あり")
	for _, p := range []float64{1, 2, 4, 8, 16, 32, 64} {
		fmt.Printf("%3.0f %7.2f %9.2f %13.2f\n",
			p, p, usl(p, 0.05, 0), usl(p, 0.05, 0.005))
	}
}

競合だけなら 64 個で 15 倍まで伸び続けます。 協調が加わると、 p=0.95/0.00513.8p^{*} = \sqrt{0.95 / 0.005} \approx 13.8 で頂点を打ち、64 個では 2.6 倍まで落ちます。 係数を α=0.05\alpha = 0.05β=0.005\beta = 0.005 と置いただけで、この差が出ます。

十分な測定点があれば、非線形回帰によって係数を推定できます。 この教材では、まず「増やしたら下がった」という形から協調の費用を疑うために使います。

何が競合で、何が協調か

係数実際に該当するもの
α\alpha 競合全体を守る 1 つのロック、1 本しかない接続、同じ行への更新、ログ出力の排他
β\beta 協調複数ワーカー間のキャッシュ整合、状態の同期、参加者どうしの一斉通信

α\alpha を減らす作業は、ロックの範囲を狭めることと、守る対象を分割することです。 更新するキーごとにロックを分ければ、別のキーを触る処理は待たなくなります。

β\beta を減らす作業は、そもそも足並みを揃えなくて済む設計にすることです。 サーバーをまたいで即座に一致させる必要が本当にあるのかを疑うと、多くは「数秒遅れてよい」に落ちます。

まとめすぎると競合が増える

往復を減らすつもりの改善が、 α\alpha を増やすことがあります。

実例があります。 高頻度の更新をメモリに溜めて、定期的に 1 つの大きなトランザクションでまとめて書き込むようにしたところ、スコアが 3 分の 2 に落ちました。 往復の回数は確かに減りましたが、まとめたぶんロックを長く持つようになり、待たされる処理が規定時間を超えたためです。

1 行ずつ高頻度に更新するほうが速いことがあります。 往復回数 VV を減らす手は、直列区間 α\alpha を伸ばしていないかとセットで見ます。

溜めてから書く実装を採るなら、ロックを取る順序をキーの順で固定します。順序が揃っていないと、待ち合わせが循環して止まります。

線形にスケールするとは

ここまでのモデルが、1 つの問いに答えます。

線形にスケールするとは、リソースを増やしたぶんだけ容量が増えることです。 この章までに扱った範囲では、それを妨げる要因を次の 3 つに分けられます。

  1. 増やしていない共有リソースが先に飽和している(台数を増やすと何が起きるか
  2. 同時に 1 つしか通れない場所がある( α\alpha
  3. 参加者どうしを揃える費用が台数とともに増える( β\beta

ISUCON の 8 時間でやっているのは、この 3 つを削る作業です。 1 リクエストあたりのリソースの使用量(サービス需要)を削って飽和点を右へずらし、ロックの範囲を狭めて α\alpha を削り、揃えなくてよいものを揃えるのをやめて β\beta を削ります。

スコアが 2 倍になった、で終わりにしないでください。 どのリソースの需要がどれだけ減り、飽和する件数がどこまで動いたのかを見ておくと、次にどこが限界になるかが先に分かります。

試してみよう

シミュレーションの α\alphaβ\beta を変えて、頂点が来る位置を調べてください。

  1. β\beta を 0.005 のまま α\alpha を 0.2 に上げると、頂点はどちらへ動きますか
  2. α\alpha を 0 にして β\beta だけ 0.01 にすると、何個で頂点を打ちますか

そのうえで、いま触っている実装の中に「同時に 1 つしか通れない場所」がいくつあるかを数えてみてください。

答えを見る

1 は、 p=0.8/0.00512.6p^{*} = \sqrt{0.8 / 0.005} \approx 12.6 なので、13.8 から少しだけ手前(左)へ動きます。 それより目立つのは高さの変化で、頂点の容量は約 5.5 倍から約 3.1 倍まで下がります。 α\alpha は頂点の位置をあまり動かさず、曲線全体を押し下げる係数です。

2 は、 p=1/0.01=10p^{*} = \sqrt{1 / 0.01} = 10 なので、10 個で頂点を打ちます。 このときの容量は 10/(1+0.01×10×9)5.310 / (1 + 0.01 \times 10 \times 9) \approx 5.3 倍です。 競合がゼロでも、協調の費用だけで頭打ちと下り坂が作れることが分かります。