BERTの推論速度を最大10倍にしてデプロイした話とそのTips

背景

はじめまして、JX通信社でインターンをしている原田です。

近年深層学習ではモデルが肥大化する傾向にあります。2020年にopen aiが示したScaling Laws([2001.08361] Scaling Laws for Neural Language Models) の衝撃は記憶に新しく、MLP-Mixerが示したように、モデルを大きくすればAttention構造やCNNでさえも不必要という説もあります。([2105.01601] MLP-Mixer: An all-MLP Architecture for Vision

しかし大きな深層学習モデルを利用しようとすると、しばしば以下のような問題に悩まされます。

  • 推論速度が問題でプロダクトに実装不可能
  • GPU/TPUはコスト上厳しい
  • プロダクトの性質上バッチ処理が不可能(効率的にGPU/TPUが利用できない)

例えばJX通信社の強みは「速報性」にあるため、バッチ処理が困難であり、効率的なGPU/TPU利用が困難です。

しかし、機械学習モデルの精度はプロダクトのUXと直結するため、「なんとかCPU上で大きなモデルを高速に推論させたい」というモチベーションが発生します。

本記事は以上のような背景から大きなNLPモデルの代表格であるBERTを利用して各高速化手法を検証します。 さらに多くの高速化手法では推論速度と精度のトレードオフが存在し、そのトレードオフに注目して検証を行います。

実際に自分は下記で紹介する方法を組み合わせた結果、BERTの推論速度を最大約10倍まで向上させ、高速に動作させることに成功しました!

まとめ

今回検証した各高速化手法の各評価は以下になります。 (☆ > ◎ > ○ > △ の順で良い)

f:id:haraso1130:20210824183731p:plain
各手法のまとめ

ただし、タスクによって各手法の有効性が大きく変わるので実際に高速化を図る際には、その都度丁寧な検証が必要です。

各手法の説明と実装コード

以下から簡単に各高速化手法の概要と実装コードを解説します。

  • pruning, quantization, distillation, torchscriptはNLP以外でも利用可能な手法
  • max_lengthはNLPモデルであれば利用可能な手法です
  • 動的なmax_lengthはバッチサイズ==1で推論するときに利用可能な手法です。

quantization(量子化)

量子化とは、浮動小数点精度よりも低いビット幅で計算を行ったり、テンソルを格納したりする技術のことです。float32からint8へ変換することが一般的です。

ここではpytorch公式を参考にしました。

pytorch.org

Pytorchでは以下の三種類の量子化が用意されており、今回は最も簡単なdynamic quantizationを学習済みモデルに適応します。

  • dynamic quantization(動的量子化)...weightsのみ量子化し、活性化はfloatで読み書きを行う。学習済みモデルにそのまま適応し、計算を行う。
  • static quantization(動的量子化)...weightsと活性化を両方量子化する。学習後にキャリブレーションが必要である。
  • quantization aware training ...weightsと活性化を両方量子化する。トレーニング中から量子化をおこなう。

実装コードは以下になります。 以下のコードでは、BERTのnn.Linearの重みをfloat32→int8に変換しています。

def quantize_transform(model: nn.Module) -> nn.Module::
  model = torch.quantization.quantize_dynamic(
    model, {torch.nn.Linear}, dtype=torch.qint8
  )
  return model

distillation(蒸留)

蒸留は大きなモデルを教師モデルとし、教師モデルより小さなモデルを作成する手法です。 特にBERTの蒸留版モデルはDistilBERT(https://arxiv.org/pdf/1910.01108.pdf) として紹介されています。

BERT-baseはtransformerを12層利用していますが、DistilBERTはその半分の6層のtransformerを持った構造になっています。

また、損失関数は以下の三つから構成されており、解釈として「masked language task(単語穴埋め問題) をこなしながら、教師モデルと近い出力と重みを獲得する」と捉えることができます。

  • BERTのoutputとの近さ
  • masked language taskでの損失
  • BERTのパラメータとのコサイン類似度

今回の実験ではバンダイナムコが公開している日本語版distillbertモデルを利用しました。 https://huggingface.co/bandainamco-mirai/distilbert-base-japanese

huggingfaceのtransformersを利用することでとても簡単に使うことができます。

from transformers import AutoTokenizer, AutoModel
  
tokenizer = AutoTokenizer.from_pretrained("bandainamco-mirai/distilbert-base-japanese")

model = AutoModel.from_pretrained("bandainamco-mirai/distilbert-base-japanese")

pruning(剪定)

モデルの重みの一定割合で0にする手法で、モデルをスパースにすることができます。

ここでもpytorch公式のtutorialに沿って実装します。

pytorch.org

どの重みを剪定するかはさまざまな研究がありますが、ここでは上記tutorialで紹介されていたL1ノルム基準で削る手法を用いました。絶対値が小さい重みは重要度が低いと考えられるため0にしてしまうという発想はとても直感的です。

実装コードは以下になります。

import torch.nn.utils.prune as prune

PRUNE_RATE = 0.2

def prune_transform(model: nn.Module) -> nn.Module:
  for name, module in model.named_modules():
    if isinstance(module, torch.nn.Linear):
        prune.l1_unstructured(module, name='weight', amount=PRUNE_RATE)
        prune.remove(module, "weight")
  return model

上記のコードではモデル中のnn.Linearの重みのうち、絶対値が小さいものから20%を0に置き換えるという処理になります。

今回は複数のPRUNE_RATEで推論速度と精度の変化を実験しました。

torchscript(Jit)

TorchScriptは、PyTorchのコードからシリアライズ可能で最適化可能なモデルを作成する手法です。Python以外のC++等のランタイムで実行可能になります。

Pytorhはdefine by run方式を採用しており、動的に計算グラフを作成します。学習時には非常に有用なこの形式ですが、プロダクション上の推論時における恩恵はほとんどありません。

そこで、先にデータを流してコンパイルしてしましまおう(実行時コンパイラを使おう)というのが大まかな発想です。

より詳細な解説は以下の記事が非常にわかりやすいです。

towardsdatascience.com

簡単に解説すると、 - Torchscriptは中間表現コード - この中間表現は内部的に最適化されており、実行時に pytorchの実行時コンパイラであるPyTorch JIT compilationを利用する。 - PyTorch JIT compilationはpythonランタイムから独立しており、実行時の情報を用いて中間表現を最適化する

実装コードは以下になります。 torchscriptにはtraceとscriptの二つの作成方法がありますが、ここでは後からでも簡単に作成できるtraceを用います。

def torchscript_transform(model):
  model = torch.jit.trace(model, (SANPLE_INTPUT))
  return model

max_length

inputのmax_lengthを制限して入力データを軽くします。 transformersで前処理を行う場合、以下のような実装になります。

from transformers import BertTokenizer

MAX_LENGTH = 512

tokenizer = BertTokenizer.from_pretrained("hoge_pretrain")

data = tokenizer.encode_plus(
            TEXT,
            add_special_tokens=True,
            max_length=MAX_LENGTH,
            padding="max_length",
            truncation=True,
            return_tensors="pt",
        )

do_not_pad

この手法はbatch_size==1で推論する場合に利用可能な手法です。

通常batch推論をするために入力データのpaddingが必要ですが、batch_size==1の状況下ではパディングを行わずに推論することができます。

実装は以下になります。padding引数に'do_not_pad'を設定するだけです。

from transformers import BertTokenizer

tokenizer = BertTokenizer.from_pretrained("hoge_pretrain")

data = tokenizer.encode_plus(
            TEXT,
            add_special_tokens=True,
            max_length=512,
            padding="do_not_pad",
            truncation=True,
            return_tensors="pt",
        )

実験方法

今回の実験は精度と速度のトレードオフを測定することが主眼であるため、丁寧に精度の調査を行います。

環境

実行環境はgoogle colabで統一してあります。

Dataset

後述しますが、データセットによって有効な手法が変わるため特性が異なる複数のサンプルタスクを用意しました。

  • 一文が長いデータセット(livedoorトピック分類)
  • 一文が短いデータセット(twitter感情分類、ポジネガの2値分類で検証)

modelについて

精度評価方法

  • まず8:2でtrain/testに分割
  • trainのみを利用し、5fold stratified cross validation(全ての実験でfoldは固定)でモデルを学習
  • 5つのモデルでそれぞれtestに対して推論、averageしたものをtestの予測値とする。
  • cvとtestのacc & f1 macroで比較

速度評価方法

  • testセットからランダムに500個のデータをサンプリングし(全ての実験で共通)、batch_size==1で推論
  • 各データに対する推論時間の平均値と標準偏差で評価

結果

まず、各手法に対するtest scoreと速度のplotは以下のようになりました。 グラフの見方ですが、以下の通りです。

  • 一番左がベースライン
  • 赤と黄色のバーは精度を表しており上方向の方が良い
  • 青い点は推論時間で下方向の方が良い
  • エラーバーは標準偏差

twitter感情分類

f:id:haraso1130:20210824180416p:plain
twitterデータに対する精度と速度

livedoorトピック分類

f:id:haraso1130:20210824180443p:plain
livedoorデータに対する精度と速度

詳細な結果は以下になります。

twitter感情分類

手法 cv acc (f1-macro) test acc (f1-macro) 平均推論速度(s) 標準偏差(s)
BASELINE 0.8295 (0.8193) 0.8363 (0.8256) 0.2150 0.0050
quantization 0.8223 (0.8092) 0.8283 (0.8150) 0.1700 0.0048
distillation 0.8388 (0.8313) 0.8292 (0.8220) 0.1547 0.0076
max_length:64 0.8212 (0.8103) 0.8250 (0.8138) 0.1156 0.0036
do_not_pad 0.8295 (0.8193) 0.8363 (0.8256) 0.0987 0.0290
torchscript 0.8295 (0.8193) 0.8363 (0.8256) 0.1847 0.0080
pruning: 0.2 0.8327 (0.8226) 0.8283 (0.8173) 0.2124 0.0043
pruning: 0.4 0.8095 (0.7972) 0.8229 (0.8100) 0.1925 0.0041
pruning: 0.6 0.7097 (0.6787) 0.7597 (0.7198) 0.1925 0.0044
pruning: 0.8 0.5809 (0.5024) 0.6220 (0.3834) 0.1912 0.0046

livedoorトピック分類

手法 cv acc (f1-macro) test acc (f1-macro) 平均推論速度(s) 標準偏差(s)
BASELINE 0.9238 (0.9180) 0.9348 (0.9285) 0.7500 0.0079
quantization 0.9022 (0.8962) 0.9246 (0.9199) 0.6565 0.0068
distillation 0.8581 (0.8494) 0.8723 (0.8646) 0.5128 0.0079
max_length:256 0.8691 (0.8630) 0.8676 (0.8605) 0.4511 0.0062
do_not_pad 0.9238 (0.9180) 0.9348 (0.9285) 0.7012 0.0926
torchscript 0.9238 (0.9180) 0.9348 (0.9285) 0.7222 0.0083
pruning: 0.2 0.9204 (0.9144) 0.9355 (0.9302) 0.7633 0.0083
pruning: 0.4 0.8674 (0.8624) 0.8900 (0.8846) 0.7682 0.0084
pruning: 0.6 0.1973 (0.1176) 0.2057 (0.1025) 0.7496 0.1045
pruning: 0.8 0.1360 (0.0950) 0.1140 (0.0227) 0.7287 0.0075

考察

それぞれの手法についてより性能をわかりやすく表示するためBASEの精度と速度を1とし、各手法の性能を考察していきます。

twitter感情分類

f:id:haraso1130:20210824180325p:plain
Twitterデータに対する相対的な精度と速度

livedoorトピック分類

f:id:haraso1130:20210824180428p:plain
livedoorデータに対する相対的な精度と速度

quantization(量子化)

どちらのタスクにおいても殆ど精度を落とさずに推論時間を10~20%ほど削減することが可能です。 実装も容易であるため、高速化の際にはまず試してみたい手法です。

distillation(蒸留)

精度面ではタスクによって大きく結果が異なることがわかります。 twitterデータに対しては殆ど精度低下が見れらませんが、livedoorデータに対してはある程度の精度低下が認められます。

推論時間については約30%ほど削減できており、タスクによっては非常に有効な選択肢になり得ます。

max_length

どちらのタスクでも推論時間を40%~45%ほど削減できており、高速化において最も安定して寄与したといえます。

非常にインパクトが大きくセンシティブなパラメータであるため、ある程度速度が求められるシチュエーションの場合、まず初めにチューニングすべきパラメータです。

do_not_pad

この手法はデータセットによって大きく効果が異なる結果となりましたが、精度は不変であるため、バッチ処理が不可能な状況下では積極的に利用すべきです。

特に最大文字数が少なく、文字数の分散が大きいと考えられるツイッターデータセットではdo_not_padの影響は大きく、葯50%の推論時間をセーブすることができました。

torchscript

精度を落とさずに、少しではありますが推論速度を向上させることができます。

また、torchscriptはその他にも多くのメリットを有しており(Python以外のランタイムで実行可能、推論時にネットワークの定義が不要など)、プロダクションにデプロイする際はONNX等と並ぶ選択肢となります。

Pruning

今回の実験ではかなり微妙な結果でした。

twittterデータセットではpruning:0.4で10%ほどの推論時間削減を達成しましたが、その他の手法のトレードオフと比較するとコストパフォーマンスが低い印象です。その他の手法を全て適応した後、それでも高速化が必要ならば検討する、といったものになるでしょう。

また、livedoorデータセットにおいてはまさかの低速化に寄与する結果となってしまいました。

まとめ

本記事ではNLPモデルを高速にCPU上で動作させるため、各高速化手法について検証してきました。 タスクによって各手法のパフォーマンスが大きく異なるため、必要な精度と速度を見極めた後、最適な高速化手法の組み合わせを模索することが重要です。

その他にも有効な高速化手法があれば教えてくださると幸いです。

参考

Reactのパフォーマンス改善を勉強会で開催しました

はじめまして、新卒フロントエンドエンジニアのぺいです。

JX通信社でフロントエンドの開発はReactが主流になっており、React Hooksを使った開発が欠かせません。hooksは便利な反面、適材適所使い所を理解していないと逆にパフォーマンスが悪くなってしまう場合があります。そこで今回は普段フロントエンドを書かない人も勉強会に参加するのを考慮し簡単な改善から応用としてReactで書かれたFASTALERT *1の改善まで行ってもらいました。

前提条件

react 17.0.1

勉強会の内容

最終的な目標として考えていた、どのようにボトルネックを発見するかを体験してもらいたかったので、簡単なパフォーマンス改善体験以外はFASTALERT本体の改善を実際に行ってもらいました。

座学では、なぜ不必要なコンポーネントが再レンダリングされてしまうのか、どのように改善するのかを行いました。

再レンダリングされているコンポーネントを見つける

  • React devツールを入れる
  • Componentsの設定からハイライトにチェックマークを入れる

f:id:PeeI:20210711151821p:plain

  • 適当に操作をすると再レンダリングされた箇所が以下のようにハイライトされる。

f:id:PeeI:20210711154018g:plain

座学では、上記のように 1ずつ増えるボタンと2ずつ増えるボタンを用意して、+2は子のコンポーネントで作りました。

以下は実際に座学で使用したコードです。以降はこのコードをリファクタリングしていきます。

// カスタムhooks
const useCounter = () => {
  const [count, setCount] = useState(0);

  const onClickIncrement = () => {
    setCount((prev) => prev + 1);
  }

  const onClickIncrementDouble = () => {
    setCount((prev) => prev + 2);
  }

  return { count, onClickIncrement, onClickIncrementDouble };
};
// 親のコンポーネント
const Component: React.FC = () => {
  const { count, onClickIncrement, onClickIncrementDouble } = useCounter();

  return (
    <>
      <button onClick={onClickIncrement}>+1</button>
      <ChildComponent onClick={onClickIncrementDouble} />
      <div>現在のカウント:{count}</div>
    </>
  );
};
// 子のコンポーネント
interface Props {
  onClick: () => void;
}

const ChildComponent: React.FC<Props> = ({ onClick }) => {
  console.log("子のコンポーネントが描画されました。");
  return <button onClick={onClick}>+2</button>;
};

本来は+2が子のコンポーネントになってるので、+1の親のボタンをクリックしても再レンダリングされてほしくはありません。しかし、子のコンポーネントまでハイライトで再レンダリングされている様子がわかります。

試しにハイライトによる確認だけではなく、Profiler による確認もしてみましょう。

f:id:PeeI:20210711161532p:plain

  • React dev ツールを入れている状態で、Profilerを選択する
  • 左端にある🔵ボタンを押してprofilingをスタートする
  • 適当に操作を行う(今回は+1をクリックする)
  • 🔴を押してprofilingを止める

そうすると Component(親)の配下に黄色で ChildComponent がレンダリングされている様子が分かります。

Profilerの詳しい説明に関してはこちらを参考にしてください。

なぜ再レンダリングされてしまうのか

結論から述べると、Reactはレンダリングされる際に propsが変わったかどうかは気にしていません

なので子のコンポーネントも無条件にレンダリングされてしまいます。

以降ではどのように、再レンダリングしないようにするかについて述べます。

改善方法

親のコンポーネントがレンダリングされると子も無条件にレンダリングされてしまうので、これをストップさせるには

  • 子のコンポーネントをメモ化する
  • 子に渡してる関数がある場合は関数をメモ化する

をしてあげる必要があります。

コンポーネントのメモ化

classコンポーネントでは、shouldComponentUpdate を使い、無駄なレンダリングを止めることができます。 shouldComponentUpdate は以下の図のようにrenderが呼ばれるより先に呼ばれ、falseを返すことでレンダリングを止めることができます。

f:id:PeeI:20210711164517p:plain

参照

shouldComponentUpdate は自分で比較式を書いてfalseを返すかどうかを決める必要があるのに対して、PureComponent は、自ら比較式を書かなくとも浅い比較(ShallowEqual)をしてくれるので公式でもレンダーを抑止するためには、shouldComponentUpdateではなくPureComponentを使うよう推奨しています。

両者ともclassコンポーネントで使われるのに対して、functionコンポーネントの場合は上記と同じような機能を持った React.memo というものがあります。

以下は先ほどの子のコンポーネントをメモ化させたものです。

import { memo } from "react";

const ChildComponent: React.FC<Props> = memo(({ onClick }) => {
  console.log("子のコンポーネントが描画されました。");
  return <button onClick={onClick}>+2</button>;
});

メモ化させたことで、propsに変更がない時はレンダリングを止めることができました。

shouldComponentUpdateはfalseを返すことで等しいという意味になりますが、React.memoはその逆でtrueを返すこと等しいということになります。詳しくは

しかし、このままでは まだ 無駄なレンダリングを止めることができません。

関数のメモ化

上記でコンポーネントをメモ化したにも関わらず、無駄なレンダリングを止めることができないのはなぜでしょうか。

それはコールバック関数をメモ化してない点にあります。

今回は、親のComponentからChildComponentにpropsとしてonClickIncrementDouble を渡しています。

const onClickIncrementDouble = () => {
  setCount((prev) => prev + 2);
}

親コンポーネント側で上記のように関数を作成するとレンダリングする度に新しい関数の参照が作成されるので、せっかくコンポーネントをメモ化させてもpropsで渡された関数が以前の参照と異なるので、レンダリングされてしまいます。

そこで同じ参照を再利用するコールバック関数の useCallback を使います。

使い方は第一引数に関数を渡して、第二引数に依存関係を配列で渡してあげます

const memoizedCallback = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

useCallbackを使うことで関数が不変値化されるので、メモ化したコンポーネントに関数を渡す時、同じ参照を渡していることになるので、メモ化されたコンポーネントでShallow Equalityされた時、前回と同じ結果を返すことができます。

ChildComponent に渡している onClickIncrementDouble をuseCallbackでラップし、Profilerで確認した結果以下のようになりました。

f:id:PeeI:20210803123525p:plain

それぞれメモ化したことで、ChildComponentは再レンダリングされなくなりました。

最終的な変更箇所

// カスタムhooks
 const useCounter = () => {
   const [count, setCount] = useState(0);

-  const onClickIncrement = () => {
+  const onClickIncrement = useCallback(() => {
     setCount((prev) => prev + 1);
-  };
+  }, []);

-  const onClickIncrementDouble = () => {
+  const onClickIncrementDouble = useCallback(() => {
     setCount((prev) => prev + 2);
-  };
+  }, []);

   return { count, onClickIncrement, onClickIncrementDouble };
 };
// 子のコンポーネント
-const ChildComponent: React.FC<Props> = ({ onClick }) => {
+const ChildComponent: React.FC<Props> = memo(({ onClick }) => {
   return <button onClick={onClick}>+2</button>;
-};
+});

毎回コンポーネントや関数をメモ化すべきなのか

今回は、再レンダリングを防ぐ方法を講義で行いました。その中で、毎回メモ化する必要があるのか という質問をされました。

結論からいうとその必要はないと思います。

逆にメモ化が必要な時は以下の時といえます。

  • 数千件・数万件のデータを計算するロジックが含まれた関数など
  • 一秒毎にレンダリングされるコンポーネントがあるなど
  • カスタムhooksを作る時

コストの高い計算

例えば、数千件・数万件のデータを加工しないといけない時、メモ化されていないとレンダリングが走る度に関数が作られ非常に重くなることがあると思います。そのような場合は、useMemo などを使いメモ化することをお勧めします。

無駄なレンダリング

例えば、画面上部にタイマーが設置されており、1秒毎に他のコンポーネントも再レンダリングされてしまう場合は無駄なレンダリングと言えます。他にも今回紹介したReact devツールを使い無駄だと思う箇所はメモ化してあげる必要があるでしょう。

カスタムhooks

講義編で説明したように、useCallbackはメモ化されているコンポーネントに関数を渡したい時に力を発揮します。逆を言うとメモ化されていないコンポーネントにメモ化した関数を渡しても意味はないです。

ではカスタムhooksで関数を定義する場合はどうでしょうか。カスタムhooksは使われるコンポーネントのことを知りません。そして、そのカスタムhooksを使うコンポーネント側もカスタムhooksの内部事情をしりません。カプセル化されている状態では関数がメモ化されているかどうかを知らないので、関数を useCallback でメモ化してあげている方が汎用的です。

最後に

勉強会中、メモ化に関する議論が最も多く上がっていました。パフォーマンスに関しては必ずこうすべきという銀の弾丸のようなものはないので、各プロジェクトにあった改善が求められると思います。今回勉強会を開催して色々な意見が出てきたので、またパフォーマンス改善に関する勉強会を開催して知見を深めたいと思いました。

参考

Python multiprocessing vs threading vs asyncio

エンジニアの鈴木(泰)です。

今回は、multiprocessingとthreadingとasyncioの違いとはなんだろう?という問に挑戦してみたいと思います。

この問の答えをグーグル先生に聞いてみると、非常にたくさんの情報がヒットします。しかしながら、どの情報も断片的なものばかりで(本記事もそうなのかもしれません)、色々と本を読んだりネットを漁ったりして、情報を補完しなければなりませんでした。

本記事は、僕が調べた限りの情報を集約し、この問に対する結論を1つの記事にまとめたものとなっています。

前提

本題に入る前に、いつくかの前提について認識を合わせておきます。

マルチプロセスとは

プロセスとは実行中のプログラムです。例えば、Pythonのソースコードを実行すると、ソースコードをインタプリターがバイトコードにコンパイルします。OSはこのバイトコードを実行し、ソースコードに書かれている通りに処理を開始します。この実行中の処理をプロセスと呼びます。

1つのプロセスは、OSから空いているCPUコアが割り当てられることにより、処理を進めることができます。当然、CPUのコアが1つだけである場合、1つのプロセスの処理だけしか進めることができません。しかし、CPUのコアが複数ある場合、それぞれのコアを複数のプロセスに対して同時に割り当てることができるため、複数のプロセスの処理を同時に進めることができます。

マルチプロセスとは、複数のプロセスが同時に処理を進めることを指します。マルチプロセスのメリットは、1つのプログラムの目的を達成するために複数のCPUのコアを利用することで、より速く目的を達成できるという点にあります。

マルチプロセス機構はOS毎に実装が異なります。OS毎の挙動の違いに注意する必要はありますが、プログラミング言語毎の挙動の違いはあまりないです。とはいえ、各プログラミング言語において、プロセスの作成をOSに対して直接に命令することは少なく、各言語毎に用意されているラッパー関数やクラスを通して行います。従って、各言語毎に、これらのラッパーの仕様の違いを知っておく必要はあります。

マルチスレッドとは

スレッドとは、プロセスの中における処理の流れのことです。「処理の流れ」という表現では曖昧でわかりにくいため、具体例で説明します。

以下のPythonのソースコードを実行すると、プロセスが作られます。このプロセスの中では、Helloの出力から始まり、!の出力で終わる処理の流れがあります。この処理の流れがスレッドです。このスレッドをメインスレッドと呼びます。このソースコードでは、プロセスが開始されたから終わるまで、処理の流れはずっとメインスレッド1つだけです。

hello.py

print('Hello')
print('world')
print('!')

以下のPythonのソースコードはthreadingライブラリを利用したマルチスレッドを実行するものです。job.start()関数がスレッドを開始します。このソースコードではprint('Hello')print('world')print('!')、そしてメインスレッドの4つの処理の流れがあります。job.join()関数の実行後はスレッドが完了します。よって、print('done')が実行される時点においては、スレッドはメインスレッドの1つだけです。

hello_threading.py

import threading

jobs = []
jobs.append(threading.Thread(target=lambda : print('Hello')))
jobs.append(threading.Thread(target=lambda : print('world')))
jobs.append(threading.Thread(target=lambda : print('!')))

for job in jobs:
    job.start()
for job in jobs:
    job.join()

print('done')

1つのスレッドは、プログラミング言語毎に実装されている機構(LinuxではPthread、JavaのThreadsライブラリ、Pythonではasyncioやthreadingライブラリ等)を通してCPUコアが割り当てられることにより、処理を進めることができます。プロセスのように、OSから直接CPUコアが割り当てられるのではありません。プロセスの場合と同様に、CPUのコアが複数ある場合、それぞれのコアを複数のスレッドに対して同時に割り当てることができれば、複数のスレッドの処理を同時に進めることができます。

マルチスレッドとは、複数のスレッドが同時に処理を進めることを指します。

一般的には、マルチスレッドのメリットもマルチプロセスのメリットと同様です。が、Pythonにおいては、CPythonがGILであるということに注意する必要があります。

Pythonにおけるマルチスレッド

Pythonにおいて、マルチスレッドなソースコードを書く場合、CPythonがGILがあることを考慮しなければなりません。スクリプト言語のインタプリターは、GILであるものとそうでないものがあります。CPythonはGILであり、JythonやIronPythonはGILではありません。ちなみにCRubyはGILです。

GILであるインタプリターにおいては、マルチスレッドなソースコードを書いたとしても、インタプリターが出力したバイトコードをOS上で実行する段階においてマルチスレッドでは実行されません。たとえば、上で掲載したhello_threading.pyは、OS上で実行される段階においてマルチスレッドでは実行されません。

本題

Pythonにおいて、マルチプロセスやマルチスレッドなソースコードを書く場合、multiprocessing、threading、asyncioのどれを利用すべきなのでしょうか?

マルチプロセス(multiprocessingライブラリ)を利用したほうが良い場合

CPU負荷の高い処理(いわゆるCPU bound)を達成するためのソースコードである場合、multiprocessingを利用し、マルチプロセスに書きましょう(Jython等のGILではないインタプリターを使うのであれば、この限りではありません)。

CPU負荷の高い処理するためにマルチスレッドなソースコードを書いたとしても、パフォーマンスは改善されません。なぜなら、「Pythonにおけるマルチスレッド」で説明した通り、Pythonのソースコードはインタプリターによりコンパイルされた後、OS上でシングルスレッドで実行されるからです。すなわち、利用できるCPUコアは1つだけに限定されます。

実際にやってみると、パフォーマンスの差が顕著に表れます。

検証環境

  • 4 vCPUs, 16 GB memory
  • CentOS, 8, x86_64 built on 20210701
  • Python3.8

cpu_sec.py

CPU負荷の高い処理burden_cpu関数を1つのプロセス、1つのスレッドで処理するプログラムです。

def burden_cpu():
    for i in range(10000):
        for j in range(10000):
            pass

for i in range(4):
   burden_cpu()

実行結果

$ time python3.8 cpu_sec.py

real    0m9.518s
user    0m9.473s
sys 0m0.005s

CPU使用率。CPUのコアが4個あるうち、1つのコアだけを使用しているため、25%となります。

$ mpstat 1
...(省略)
16:08:49     CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest  %gnice   %idle
16:08:49     all   18.50    0.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00   81.50
16:08:50     all   25.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00   75.00
16:08:51     all   25.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00   75.00
16:08:52     all   25.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00   75.00
16:08:53     all   24.88    0.00    0.25    0.00    0.25    0.00    0.00    0.00    0.00   74.63
16:08:54     all   24.81    0.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00   75.19
16:08:55     all   25.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00   75.00
16:08:56     all   24.75    0.00    0.00    0.00    0.50    0.00    0.00    0.00    0.00   74.75
16:08:57     all   25.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00   75.00
...(省略)

cpu_multiprocessing.py

CPU負荷の高い処理burden_cpu関数を4つのプロセス、各プロセス上では1つのスレッドで処理するプログラムです。

import multiprocessing as mp

def burden_cpu(_: any):
    for i in range(10000):
        for j in range(10000):
            pass

pool = mp.Pool(4)
pool.map(burden_cpu, [i for i in range(4)])
pool.close()

実行結果。CPUを効率良く使用できている(下記参照)ため、cpu_sec.pyの実行時間よりも小さくなります。

$ time python3.8 cpu_multiprocessing.py

real    0m5.351s
user    0m21.062s
sys 0m0.028s

CPU使用率。CPUのコアが4個あるうち、4つプロセスに対して1つずつコアが割り当てられ、同時に4つのコアを使用しているためほぼ100%となります。

$ mpstat 1
...(省略)
16:12:08     all   99.50    0.00    0.00    0.00    0.50    0.00    0.00    0.00    0.00    0.00
16:12:09     all   99.01    0.00    0.00    0.00    0.99    0.00    0.00    0.00    0.00    0.00
16:12:10     all   99.75    0.00    0.00    0.00    0.25    0.00    0.00    0.00    0.00    0.00
16:12:11     all   99.25    0.00    0.00    0.00    0.75    0.00    0.00    0.00    0.00    0.00
...(省略)

cpu_threading.py

CPU負荷の高い処理burden_cpu関数を1つのプロセス、4つのスレッドで処理するプログラムです。

from concurrent.futures import ThreadPoolExecutor

def burden_cpu():
    for i in range(10000):
        for j in range(10000):
            pass

pool = ThreadPoolExecutor(max_workers=4)

for i in range(4):
    pool.submit(burden_cpu)
pool.shutdown()

実行結果。ソースコード上では4つのスレッドが同時に処理を進めていますが、バイトコード上では1つのスレッドだけが処理を実行しているだけの状態(下記参照)であるために、cpu_sec.pyの実行時間とほぼ同じです。

$ time python3.8 cpu_threading.py

real    0m9.812s
user    0m9.820s
sys 0m0.090s

CPU使用率。CPU使用率が25%程度であることから、CPUのコアが4個あるうち1つだけしか利用できていないことがわかります。

$ mpstat 1
...(省略)
16:16:46     all   25.00    0.00    0.25    0.00    0.00    0.25    0.00    0.00    0.00   74.50
16:16:47     all   24.88    0.00    0.25    0.00    0.50    0.00    0.00    0.00    0.00   74.38
16:16:48     all   24.75    0.00    0.00    0.00    0.25    0.00    0.25    0.00    0.00   74.75
16:16:49     all   24.81    0.00    0.25    0.00    0.00    0.25    0.00    0.00    0.00   74.69
16:16:50     all   25.00    0.00    0.25    0.00    0.50    0.00    0.00    0.00    0.00   74.25
16:16:51     all   24.75    0.00    0.00    0.00    0.25    0.00    0.25    0.00    0.00   74.75
16:16:52     all   24.94    0.00    0.25    0.00    0.25    0.00    0.00    0.00    0.00   74.56
16:16:53     all   24.69    0.00    0.25    0.00    0.00    0.00    0.00    0.00    0.00   75.06
16:16:54     all   25.31    0.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00   74.69
...(省略)

cpu_asyncio.py

asyncioを使用した場合です。実行結果は、cpu_threading.pyのものと同じです。

import asyncio

running = 0

async def burden_cpu_async():
    global running
    for i in range(10000):
        for j in range(10000):
            pass
    running-=1

async def main():
    await asyncio.gather(*[
        burden_cpu_async(),
        burden_cpu_async(),
        burden_cpu_async(),
        burden_cpu_async(),
    ])

asyncio.run(main())

実行結果。

$ time python3.8 cpu_asyncio.py 

real    0m9.433s
user    0m9.389s
sys 0m0.007s

CPU使用率。CPU使用率が25%程度であることから、CPUのコアが4個あるうち1つだけしか利用できていないことがわかります。

$ mpstat 1
...(省略)
01:10:20     all   24.94    0.00    0.00    0.00    0.25    0.00    0.00    0.00    0.00   74.81
01:10:21     all   24.81    0.00    0.00    0.00    0.25    0.00    0.00    0.00    0.00   74.94
01:10:22     all   25.00    0.00    0.00    0.00    0.25    0.00    0.00    0.00    0.00   74.75
01:10:23     all   24.81    0.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00   75.19
01:10:24     all   24.88    0.00    0.00    0.00    0.50    0.00    0.00    0.00    0.00   74.63
01:10:25     all   24.81    0.00    0.25    0.00    0.00    0.00    0.00    0.00    0.00   74.94
01:10:26     all   24.94    0.00    0.00    0.00    0.00    0.00    0.25    0.00    0.00   74.81
01:10:27     all   24.75    0.00    0.00    0.00    0.25    0.00    0.00    0.00    0.00   75.00
...(省略)

ソースコード毎の実行結果まとめ表

ソースコード プロセス スレッド 実行時間(秒) CPU使用率(%)
cpu_sec.py 1 1 9.518 25.0
cpu_multiprocessing.py 4 1 5.351 99.75
cpu_threading.py 1 4 9.812 25.0
cpu_asyncio.py 1 1 9.433 25.0

threadingとasyncioを利用したほうが良い場合

I/O待ち時間が大きいもの(いわゆるI/O bound)を達成するためのソースコードである場合、threading(マルチスレッド)かasyncio(非同期I/O)を利用しましょう。

multiprocessing(マルチプロセス)を利用しない方が良い理由は、プロセスを作る際に発生するコストが大きいからです。プロセスを作るコストよりもスレッドを作るコストの方が小さいので、コストが小さい方を利用した方が良いということです。プロセスを新しく作ると、新しく作られたプロセスの数に比例してファイルディスクリプタ数、OSがCPUを切り替えるためのスイッチング回数が大きくなります。

同様にして、スレッドを作るコストという観点から言えば、スレッドを作るコストよりも非同期I/Oのイベントを発火するコストの方が小さいため、asyncioを利用する方が良いと言えそうです。

果たしてどうなのでしょうか?検証していきたいと思います。

threading vs asyncio

誤解を恐れずにいえば、threadingとasyncioは本質的にはどちらも、Pythonにおける「複数の処理を同時に進めるための仕組み」を提供するライブラリです。どちらにおいても、「Pythonにおけるマルチスレッド」にて述べた通り、インタプリターが出力したバイトコードはOS上で1つのスレッドでのみ実行されます。

両者の差異は次の点にあります。

  • threading
    • 昔からある。Python1.6(2000年)から標準ライブラリにあります。
    • 昔からある、マルチスレッドプログラミングというパラダイムに属する。
      • PythonのthreadingライブラリのAPIは、なんとなくですが、Javaのマルチスレッドに似ています。
    • 複数のスレッドを作り、それぞれの処理を同時に進めることができる。
      • 競合状態(Race Condition)に気をつけなければならない。
  • asyncio
    • 2015年(Python3.4)から導入された。
    • ここ10年ぐらいで広まってきた非同期プログラミングというパラダイムに属する。
    • ある処理がI/O待ちをしている間に他の処理を進めることができる。このことからわかるように、厳密に言えば、複数の処理を同時に進めているわけではなく、「待ち」が発生した時に、他に進めることのできる処理(待ちが発生していない処理)を進めているだけである(非同期I/Oについての詳細な説明は本記事では割愛します。詳しく知りたい方は、グーグル先生に聞いてみてください。)
      • 競合状態(Race Condition)をあまり気にする必要はないが、程よく、非同期I/Oの「待ち」(例asyncio.sleep関数など)が入るようなプログラムを書かなければならない(「待ち」が入らない場合、コンテキストスイッチングが起こらない。)

一見するとthreadingよりもasyncioを利用する方が良さそうですが、実際のところどうなのでしょうか?I/O boundな処理をそれぞれのライブラリを利用して書き、比較してみましょう。

検証環境

  • 4 vCPUs, 16 GB memory
  • CentOS, 8, x86_64 built on 20210701
  • Python3.8

比較方法

比較に用いられる検証用プログラムは2つあります。io_threading.pyとio_asyncio.pyです。

io_threading.py、io_asyncio.pyはそれぞれ、I/O boundなタスクを処理する常駐プログラムです。ウェブサーバーのようなプログラムを模倣しています。ウェブサーバーはポートに届いたリクエストを処理します。これを模倣し、検証用プログラムは標準入力に届いたタスクを処理します。ウェブサーバーには、リクエストの処理をスレッドに任せるもの(nginxのような)と、イベントループに任せるもの(node.jsのような)があります。io_threading.pyは標準入力に届いたタスクの処理をスレッドに任せます。一方、io_asyncio.pyはイベントループに任せます。

タスクは検証用プログラムの標準入力に入力されます。入力された文字列は数字でなければなりません。この数字は入力されたタスクの量を表します。max_weight_io_burdenが、検証用プログラムが処理しなければならないタスクの総量です。検証用プログラムが処理したタスクの量の和がタスクの総量を超えると、プログラムは終了します。

タスクはI/O boundなものです。タスクの量はI/O待ちの時間(秒)です。io_burden関数が、I/O boundなタスクを模倣します。

io_threading.pyとio_asyncio.pyは、タスクの総量をどれだけ速く終わらせることができるのか?を競います。

io_threading.py

I/O boundな処理をthreadingを用いて捌く実装です。

import threading
import fileinput
import time
import os

max_weight_io_burden = int(os.getenv('MAX_WEIGHT_IO_BURDEN'))

start = None

# 処理済のタスクの量
processed_weight = 0
processed_weight_lock = threading.Lock()

def io_burden(weight: int):
    # I/O boundな処理を模倣した関数
    # weight引数に指定された秒数だけ待ちを発生させます
    global processed_weight
    global processed_lock
    time.sleep(weight)
    with processed_weight_lock:
        processed_weight += weight
        if processed_weight >= max_weight_io_burden:
            print(time.time() - start, processed_weight)

def get_input():
    global start
    inputs = 0
    for line in fileinput.input():
        # 標準入力のタスクを受け取る
        # weightがタスクの量
        weight = int(line)
        if inputs == 0:
            start = time.time()
        if inputs >= max_weight_io_burden:
            # 処理済みのタスクの量がmax_weight_io_burdenに到達したらループを抜ける
            break
        # スレッドを生成し、タスクを処理するスレッドを開始
        t = threading.Thread(target=io_burden, args=(weight,))
        t.start()
        inputs += weight
    # 処理中のスレッドが全て終わるまで待つ
    while threading.active_count() > 1:
        pass

get_input()

io_asyncio.py

I/O boundな処理をasyncioを用いて捌く実装です。上記のio_threading.pyのasyncio版です。

import threading
import fileinput
import time
import os
import asyncio

max_weight_io_burden = int(os.getenv('MAX_WEIGHT_IO_BURDEN'))

start = None
processed_weight = 0

async def io_burden(weight: int, loop):
    global processed_weight
    await asyncio.sleep(weight)
    processed_weight += weight
    if processed_weight >= max_weight_io_burden:
        loop.stop()
        print(time.time() - start, processed_weight)

def get_input(loop):
    global start
    inputs = 0
    for line in fileinput.input():
        weight = int(line)
        if inputs == 0:
            start = time.time()
        if inputs >= max_weight_io_burden:
            break
        # タスクを処理するコルーチンをイベントループに登録する
        asyncio.run_coroutine_threadsafe(io_burden(weight, loop), loop=loop)
        inputs += weight

loop = asyncio.get_event_loop()

thread_input = threading.Thread(target=get_input, args=(loop,))
thread_input.start()

loop.run_forever()

プログラムの実行方法

このプログラムは2つの端末により実行します。

1つ目の端末では、検証用プログラムを動かします。環境変数MAX_IO_BURDEN_TASKSはプログラムが処理するタスクの総量です。

# 実行例
# プログラムを起動。このプログラムはタスクを1000000だけ処理したら終了する。
$ tail -f a.txt | MAX_IO_BURDEN_TASKS=1000000 python3.8 io_threading.py
# プログラムを起動。このプログラムはタスクを1000だけ処理したら終了する。
$ tail -f a.txt | MAX_IO_BURDEN_TASKS=1000 python3.8 io_asyncio.py

2つ目の端末では、プログラムにタスクを投入します。

# 量1のタスクを投入し続ける
$ while true; do echo "1" >> a.txt; done
# 量10のタスクを投入し続ける
$ while true; do echo "10" >> a.txt; done

実行結果

io_threading.py

プログラム MAX_IO_BURDEN_TASKS 単タスクの量(秒) 処理時間(秒) 備考
io_threading.py 1,000,000 1 188.2251 (1)
io_threading.py 1,000,000 2 98.5299 (1)
io_threading.py 1,000,000 3 67.4113 (1)
io_threading.py 1,000,000 4 54.4028 (1)
io_threading.py 1,000,000 5 - (2)
io_threading.py 1,000,000 30 - (2)
io_threading.py 1,000,000 40 43.4900 (4)
io_threading.py 1,000,000 50 52.6634 (4)
io_threading.py 1,000,000 100 101.3432 (4)

io_asyncio.py

プログラム MAX_IO_BURDEN_TASKS 単タスクの量(秒) 処理時間(秒) 備考
io_asyncio.py 1,000,000 1 127.0902 (1)
io_asyncio.py 1,000,000 2 71.9533 (1)
io_asyncio.py 1,000,000 3 50.1331 (1)
io_asyncio.py 1,000,000 4 36.5489 (1)
io_asyncio.py 1,000,000 5 29.2290 (1)
io_asyncio.py 1,000,000 6 25.2520 (1)
io_asyncio.py 1,000,000 7 22.3903 (1)
io_asyncio.py 1,000,000 8 20.4432 (1)(3)
io_asyncio.py 1,000,000 9 20.1911 (1)(3)
io_asyncio.py 1,000,000 10 19.8268 (3)
io_asyncio.py 1,000,000 20 24.8514 (4)
io_asyncio.py 1,000,000 30 33.0301 (4)
io_asyncio.py 1,000,000 40 42.3853 (4)
io_asyncio.py 1,000,000 50 51.8705 (4)
io_asyncio.py 1,000,000 100 100.7834 (4)

実行結果の考察

(1)過度なタスク分割によるオーバーヘッド増大

threading、asyncio共に、最も処理時間が大きくなっています。これはスレッドやイベントループ、その他諸々のオーバーヘッドの影響が大きくなってしまったことが起因していると考えられます。マルチスレッドのメリットは大きなタスクを小さなタスクに分割し、複数のタスクを複数のスレッドが同時に処理することで、全てのタスクを速く終了させるための手法です。タスクを小さくすればするほどそれぞれのスレッドは速く終了しますが、よりたくさんのスレッドを生成・管理しなければなりません。非同期I/Oでも同様に、タスクを小さくすればするほどそれぞれのタスクは速く終了しますが、よりたくさんのタスクを非同期I/Oのイベントループに登録・管理しなければなりません。また、今回のプログラムの場合、タスクを小さくすればするほどタスクを標準入力から読み込む回数も大きくなります。

asyncioの方がthreadingよりも処理時間が小さいです。これは非同期I/Oのイベントループのタスクの登録・管理にかかる時間の方が、スレッドの生成・管理のそれよりも小さいからであると考えられます。非同期I/Oの方がマルチスレッドよりもコンテキストのスイッチングに関わるオーバーヘッドが小さいという一般論にも合致します。

(2)OSのスレッド数上限値が影響

OS上で稼働しているスレッド数が、実行環境の上限値に引っかかってしまい、エラー終了します。

$ tail -f a.txt | MAX_WEIGHT_IO_BURDEN=1000000 python3.8 io_threading.py 
Traceback (most recent call last):
  File "io_threading.py", line 43, in <module>
    get_input()
  File "io_threading.py", line 37, in get_input
    t.start()
  File "/usr/lib64/python3.8/threading.py", line 852, in start
    _start_new_thread(self._bootstrap, ())
RuntimeError: can't start new thread
32702
libgcc_s.so.1 must be installed for pthread_cancel to work

実行環境OSはLinuxです。1プロセス毎に作ることのできるスレッド数には上限値があります。CPythonのスレッドはpthreadを用いて実装されているため、この上限値の影響を受けます。

$ cat /proc/sys/kernel/threads-max
126329

(3)パフォーマンス頭打ち

最も処理時間が小さいですが、(4)で述べる理想的なパフォーマンスの向上が頭打ちになっている状態です。(1)で述べたようなオーバーヘッドの影響が出始めてきたものと思われます。

(4)タスク分割数に比例してパフォーマンス向上

単タスクの量と処理時間がほぼ同じです。マルチスレッド、非同期I/O、共に、理想的なパフォーマンスの向上が実現できています。「理想的な」という所以は、大きなタスクを小さく分割した分だけ、処理時間が向上しているからです。

io_threading.pyとio_asyncio.pyの処理時間に差異がほとんどありません。これは(1)で述べたようなオーバーヘッドの影響が無視できるほど小さいからだと思われます。

まとめ

今回の試行錯誤から得られた結論は次です。

  • CPU負荷の高い処理(いわゆるCPU bound)を達成したいのであれば、マルチプロセス(multiprocessing)を利用。
  • I/Oの待ち時間が大きい処理(いわゆるI/O bound)を達成したいのであれば、マルチスレッド(threading)か非同期I/O(asyncio)を利用。
  • 同時に実行しているスレッド数が大きい場合において、非同期I/Oのパフォーマンスの方が良い。ただし、同時に実行しているスレッド数が大きくない場合においては、マルチスレッドと非同期I/Oのパフォーマンスの差異はあまりない。

参考

JX Press Tech Talk #python で「StreamlitとFlaskではじめる爆速プロトタイピングとTV砲対策」というトークをしました

JX通信社シニア・エンジニアかつ, 最近は自社のテックイベント「JX Press Tech Talk」の司会者をやってる@shinyorke(しんよーく)です.

6/23(水)に, 「JX Tech Talk #python Pythonista 達が語る速報サービス開発の舞台裏」というイベントを開催しました.

jxpress.connpass.com

参加いただいた皆さま, ありがとうございました!

私は前述の通り, このイベントの司会をさせていただいたと同時に, 登壇者として「StreamlitとFlaskではじめる爆速プロトタイピングとTV砲対策」というテーマでトークもさせていただきました.

このエントリーでは, 発表後のフィードバック・ご意見等を踏まえた上で,

  • 当日お話したこと
  • ちょっとした補足
  • JX Press Tech Talkについて

というテーマで軽く書きたいと思います.

TL;DR

  • エンジニアとデータサイエンティストが共存するようなプロジェクトの進め方は結構大事
  • Streamlitでプロトタイピングするときに合わせてテストを書こう
  • App EngineでできないことはCloud Runに任せるといい感じになる

おしながき

当日お話したこと

最初に軽く当日の話をふりかえります.

私のトークは,

  • Streamlitでプロトタイピング(プロトタイプ開発)してチームに共有する
  • Flask + GCP(Google App Engine, Cloud Run)でスケーラビリティある構成でシステムを開発・運用

という2本の軸でお話をしました.

Streamlitでプロトタイピング

議論のネタになるプロトタイプの用意がマスト(かつnotebookじゃないほうがいい)

という思いでStreamlitを使いました.

こちらのお話, 実は過去にこのブログにも書いたことでもありました.

tech.jxpress.net

JX Press Tech Talkでプレゼンしたデモのコードも上記エントリーで紹介したものとなります.

github.com

当日のトークでお話をしました,

  • notebookからstreamlitへの移行
  • ngrokを使ってチームに共有

というお話はこのエントリーのダイジェストであり, サンプルコードとして提供しているものでもありました.

当日お越し頂いた方も, このエントリーから知った方もぜひ手元で試してもらって, 「便利そうだな」って思ったら仕事や趣味に活かしてもらえると幸いです.

軽量FWとGCPを使ったプロダクト開発と運用

後半戦の話は,

  • プロトタイプから本プロダクト開発はFlask, FastAPIを使いました
  • 高負荷対策を楽にするためGoogle App Engine + Cloud Runにしました

という話でした.

Flaskの話メインでしたが, こちらについては「プロトタイプからの移植というストーリーから逆算して作るにはどうしたらいいか」というテーマで主にパッケージ構成とテストの話をしました.

f:id:shinyorke:20210629212143j:plain

Pythonは「データサイエンティストの人が作ったモデルをそのまま同じ言語でWebのプロダクトとして開発できる」明確な強みがある一方,

  • データサイエンティストが書くコードと, プロダクトのエンジニア*1が書くコードは(それぞれの領域・メンタルモデルが異なるため), 大切にする価値観・趣が異なる
  • なぜかといえば, データサイエンティストがやることはプロトタイプで, プロダクトのエンジニアは保守運用を目指して開発するから

という問題を抱えやすいため,

データサイエンティストとエンジニア両者の間を取るため, 「パッケージ構成とテストコード」を最後の砦とする

というルールで進めました.

この, 「データサイエンティストとエンジニアが指向するメンタルモデルの違い」は語ると長いので, 気になる方は「仕事ではじめる機械学習第2版」をご覧頂ければと思いますが, 割とありがちな課題だったりするのでAIプロジェクトをやる方はぜひ意識するといいと思います.*2

ちなみにこのプロジェクトでは「データサイエンティスト」「エンジニア」は私一人の役割(兼任)であったため, この問題は発生しませんでした.*3

また, 「TV砲に耐えるための高負荷対策」の件は, こちらのブログの内容そのままだったりします.

tech.jxpress.net

基本的にはこのエントリーのダイジェストという形でお話しました.

「GCPで作ったサービスをいい感じにTV砲対策する」ノウハウをまとめたつもりなので気になる方はぜひ読んで頂ければと思います.

なお, これがAWSや他のクラウドサービスであったとしても考え方は流用できるんじゃないかなと思ってます.

ちょっとした補足 - 当日話さなかったこと

当日および後日頂いたフィードバック・質問に対する補足です.

一部サービスをCloud Runで切り離した理由

最初はApp Engineのみでイケると思ったのですが,

  • SNSシェア用のOGP画像などで独自フォントが必要だった
  • その他, プロダクトの細かい仕様の制約

という理由で画像の生成のみCloud Runで切り離しました.

tech.jxpress.net

こちらも細かい話はブログに記載していますので気になる方はぜひチェックしてみてください.

当日話さなかった答えはすべてここにあります.*4

FastAPIからFlaskへの書き換え

プロトタイプの段階でFastAPIを使った簡易的なRESTful APIを用意していたのですが, このときはなぜかApp Engineで動かず, 調査する時間もさほどなかった為, Flaskに書き換えました.

ちなみに後日, 同僚から「App Engine, FastAPIでも動くやで」と聞いた&gunicorn使ってuvicorn動かせばよかったのねと気がついたのがJX Press Tech Talkの準備をしていた今月の話でもありました.

ちょっと見れば書き換えいらなかったかも...という後悔を覚えつつも, 極力Framework依存を減らした構成をとっていたので傷口はかなり浅く済んだのではと満足しています.

JX Press Tech Talkについて&結び

JX Press Tech Talk #python では, 私のトークの他,

  • @kimihiro_nさんによる, 「新しいメンバーにMake debutしてもらいやすくするための開発体制 with Python」
  • サーバーサイドエンジニアの鈴木さんによる「Python on Google Cloud Functionsで作るバッチ処理」

といった, JX通信社の開発チームで実際あった話・ノウハウの話がありました.

どちらも現場発の情報で参考になるんじゃないかなと思います.

また, イベント参加者のフィードバックにつきましても,

  • チャレンジできる環境があるのは素晴らしい
  • 多くの学びがあった

など, アンケート含めて好意的なご意見・今後の学びになるご意見を多数いただきました.

個人的には, 「司会が聞きやすかった」「進行が上手」というフィードバック嬉しかったです苦笑*5

次回は未定ですが, またお会いできる日を楽しみにしております!

なお, 最後に大事な話をしますが,

サーバーサイドエンジニアをはじめ, 絶賛募集中です!

jxpress.net

最後までご覧いただきありがとうございました.

*1:あえて「プロダクトのエンジニア」と書いたのは, 同じエンジニアでもデータサイエンティスト寄りでプロトタイピングががメインの方もいるので狭義の意味で縛る意味で「プロダクトの」という枕詞を付けました

*2:仕事ではじめる機械学習第2版の6章に詳しい話があります, 結構面白い話なのでオススメです.

*3:が, 今後はチームでやるとか普通にあり得るので一人の段階でも最初から考えてやりました.

*4:一時期, 当日の話でもやろうかなと考えていましたが, 尺が15分のトークで3つのテーマを話すのはキツイなという理由で画像生成の話はブログで先行して書いてリリースし, StreamlitとGCPの話をメインにするという決断をしました.

*5:毎回, 塩梅とか進行に苦心しているのでホント嬉しかったです, ありがとうございます&今後もがんばります

Slackアプリ開発の社内勉強会を開催しました

サーバサイド開発やインフラ周りをいじっているたっち(TatchNicolas)です。

JX通信社の日々の運用では、Slack workflowやbotが大活躍しています。

かなり作り込まれた高機能なBotもあり欠かせないものになっていますが、開発者メンバーのなかには普段そのリポジトリを触らない人・すでにあるものに機能追加・改修はするがゼロから立ち上げたことはない人などもいます。ハードルをグッとさげることで自分たちの斧を研ぎやすくできないか?と考えました。

そこで毎月開催している社内勉強会にて、今回はSlackアプリ開発をテーマにしましたのでその様子について紹介します。

内容

初めて触る人でも開発をすぐに始められるように、社内でよく使われる言語でテンプレになるリポジトリを用意しました。

また、Permissionの設定などは最初はとっつきにくいため、Tandem*1で複数人の画面共有をしながらお手伝いしつつ進めました。 その後基本的なポイントや概念の説明をして、みんなでワイワイしながら開発していく形式で会を進行しました。

最初に知っておくと良い概念

初めてSlackアプリケーションを開発する人にもわかりやすいように、前述の雛形や初期設定の他に知っておくと入りやすい概念について簡単に説明をしました。

Socket Mode

f:id:TatchNicolas:20210618214336p:plain

普通にSlackアプリケーションを開発すると、Slackからのイベントを受け取るのにpublicにhttpでリクエストを受けられるURLが必要です。ngrokなどのツールを使って用意しても良いですが、より手軽にSlackアプリケーションを開発できる方法として Socket Mode があります。

Socket ModeではpublicなURLを持つ場所へデプロイする必要がなく、ささっと手元ですぐにSlackアプリケーションを動かすことが可能です。

今回の勉強会には十数人が参加して、その分だけデプロイ先の環境を用意するのも大変ですし、勉強会のあとでお片付けも必要です。デバッグの容易さも含めて気軽さを優先するために上記のサンプルリポジトリではSocket Modeでテンプレートを作りました。

Event Subscription

f:id:TatchNicolas:20210618214405p:plain

Botがメンションを受け取ったり、誰かがチャンネルに入ったりなどSlack上の出来事のうち、どのイベント種別を受け取るかを設定するのがEvent Subscriptionです。

api.slack.com

たとえばPythonではデコレータの形で指定して、受け取ったイベントに対して処理を行う関数を書いていくことになります。

from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

app = App(token=os.environ["SLACK_BOT_TOKEN"])

@app.event("app_mention")
def print_mention_event(event):
    """
    メンションが来たら発火する関数
    """
    print(event)

作品紹介

成果発表タイムで共有された作品を紹介します。限られた時間でしたが、なかなか面白いBot達が色々あって楽しい時間となりました。

画像認識(YOLO)Bot

f:id:TatchNicolas:20210618220132p:plain

形態素解析Bot

f:id:TatchNicolas:20210618220752p:plain

gou

f:id:TatchNicolas:20210618220816p:plain

f:id:TatchNicolas:20210618220824p:plain

社内で「いいこと」をした人をSlackで讃えたり ++ とインクリメントを送るとポイントとして記録するカルマボットをGoで実装したもの。*2

github.com

占いBot

f:id:TatchNicolas:20210619075529p:plain

なぜか極端にてんびん座に厳しくて笑いました。*3

まとめ

複数の言語で雛形となるリポジトリを用意し、初期設定の説明を画面共有しながら一緒にすることで、初めての方達にもすぐに手を動かして楽しんでもらうことができました。

クスッとくるようなBotから、普段の業務を生かした画像認識や形態素解析をBot化した作品もあり、JXらしさのある楽しい勉強会になりました。

Pythonトークイベントの告知

2021/06/23(Wed) 19:30から Pythonにまつわる色々な話をするイベントを予定しています。ぜひ参加してみてください。

jxpress.connpass.com

*1:最近JX通信社で使われているチームやプロジェクトごとの部屋に出入りして使う「バーチャルオフィス」なツールです https://tandem.chat/

*2:Goで業...

*3:漢字の星座名で条件を引っ掛けているので、ひらがなだとデフォルトで適当に返答するように作ったそうです(笑)。