コネヒト開発者ブログ

コネヒト開発者ブログ

配列と連結リストの線形探索における計算速度とキャッシュメモリの重要性

こんにちは!バックエンドエンジニアのjunyaUと申します。

最近はコンビニに売っている生ラムネにハマっていて、1日2袋のペースで食べ続けていましたが、ここ数日コンビニに置いていなくて悲しい思いをしています。

今回は配列と連結リストおいて、線形探索の計算速度はどちらが速いのか、キャッシュメモリがどう関わっているのかについて考えていこうと思います〜!

はじめに

なぜ記事を書こうと思ったのか

最近、個人開発で物理メモリ管理の実装をする機会がありました。

その中で、配列を線形探索する処理があったのですが、

「連結リストと配列を線形探索する時に、どちらの方が計算速度が速いんだろう」

とふと疑問に思ったのがきっかけです。

この問題を調べて勉強していくうちに、キャッシュメモリの存在が両者の計算速度の違いに関わっていることがわかりました。

そこで、配列と連結リストのデータ構造の違い、キャッシュメモリがどのようなものなのかについて触れながら両者の線形探索における計算速度の違いを見ていこうと思います!

なお、ここでの連結リストは単方向の連結リストとします。

結論

配列と連結リストは共に線形探索の時間計算量がO(n)ですが、計算速度は配列の方が速いです。

これは、キャッシュメモリのヒット率が配列の方が高いからです。

連結リストはメモリ上で非連続的に配置される可能性がありますが、配列はメモリ上に連続で配置されることが保証されています。

連続してメモリ上に配置されているので、キャッシュラインに配列の他の要素が含まれる可能性が高いため、キャッシュヒット率が連結リストよりも高くなるのです。

これを理解するには配列と連結リストの構造の違い、キャッシュメモリの概要や特性を知る必要があるので、次節から詳しく見ていきたいと思います。

配列と連結リスト

配列の特徴

私がプログラミングを勉強し始めた頃、配列は「複数のデータを入れることができる箱」と学びました。

しかしそれだけでは、配列がどのような実態を持っているのかを掴めないので、もう少し具体的に定義すると、

「メモリ上に連続して配置された、同じ種類のデータの集合」と言えます。

メモリと配列の簡略図
上の図は、5つの要素を持つ配列を示しています。各要素のサイズは8バイトと仮定しています。

メモリに連続して配置されているので、要素のサイズ分アドレスがずれて要素が配置されていることがわかるかと思います。

連続して配置することによって、いくつかの特性や利点が生まれます。

ランダムアクセス可能

ランダムアクセスは、インデックスを用いて任意の要素に直接アクセスする操作を指します。

例えば、array[2]のような操作と考えれば馴染み深いと思います。

このような直接アクセスが可能なのは、配列の要素がメモリ上に連続して配置されているためです。

アクセスのメカニズムは単純で、arrayという変数には配列の先頭アドレスが格納されており、このアドレスに「要素のサイズ × インデックス」を加えることで、目的の要素のアドレスを瞬時に計算することができます。

array[3] というアクセスがあった場合:

0x10000 + (8 × 3) = 0x10018

この計算でアドレスを求めることによって、O(1)の時間計算量で該当の要素にアクセスすることができます。

配列のインデックスが0から始まる理由も、このメモリ上の連続した配置と密接に関連しています。「なぜ0から?」と初めは誰もが疑問に感じると思いますが、メモリの観点から考えると、アドレス計算を効率化するためであることが明白になります。

削除と挿入操作時のオーバーヘッドが大きい

配列の末尾に対して、挿入や削除をする場合の時間計算量はO(1)です。

しかし先頭や中間に挿入や削除を行う場合、追加の時間計算量がかかります。

配列からindexが2の要素を削除する簡略図
上図は、indexが2の要素を削除した場合の配列を示しています。

削除すると、その位置に空きが生まれます。この空きを埋めるために、削除した要素以降の要素を全て1つ前にずらす必要が出てきます。この操作は、特に配列が大きい場合、大きなオーバーヘッドが発生します。

今回のケースではindex3とindex4の要素を1つ前にずらすという操作が必要となります。

このようなオーバーヘッドを考慮すると、頻繁に削除や挿入が行われるケースでは、配列を使うのではなく、他のデータ構造を使うべきだと言えます。

固定長

C, C++, Java, および Go などの配列は固定長であり、一度確保したサイズを途中で変更することはできません。主な理由は、配列が連続したメモリ領域に配置されているためです。

拡張をしようとしても、配列の直後のメモリには別のデータが配置されている可能性があります。

また、仮に配列を後に拡張するために余分にメモリを確保するとしても、それが後で使用されるかどうか予測することが難しく、メモリの浪費につながります。

なので、データ長を動的に変えたい場合、配列は適したデータ構造ではありません。

連結リストの特徴

連結リストは、複数のデータを管理するという観点では配列と同じですが、そのデータ構造は全く違います。

配列はデータを連続したメモリ領域に確保しますが、連結リストはそのような連続性を保証しません。

単方向連結リストとメモリの簡略図
上図を見ると、それぞれの要素が非連続に配置されていることがわかります。

連結リストの各要素(ノードとも呼ばれる)はデータと、次の要素への参照(ポインタ)を持っています。このポインタをたどることで、次の要素にアクセスすることができ、非連続でも複数データを管理することができます。

このように非連続で配置することによって、いくつかの特性や利点が生まれます。

ランダムアクセスはできない

連結リストは、特定の要素への直接アクセスが配列と比べると非効率です。

配列ではarray[3]のように特定のインデックスを指定して、O(1)の時間計算量でその要素にアクセスできます。

一方、連結リストではデータが連続したメモリ領域に格納されていないため、ベースアドレスを使用してインデックス計算で要素のアドレスを直接特定(ランダムアクセス)することはできません。

n番目の要素にアクセスするためには、連結リストの先頭から、指定された位置に達するまで各要素のポインタを逐次たどる必要があります。

この操作はO(n)の時間計算量がかかることになります。

そのため、特定の要素への高速なアクセスが必要な場合は、連結リストよりも配列を使用する方が適切です。

削除と挿入時のオーバーヘッドが小さい

配列では先頭や中間への要素の挿入や削除に大きなオーバーヘッドがかかりますが、連結リストではこれらの操作をより小さいオーバーヘッドで行うことができます。

連結リストからNode2を削除する簡略図
上図ではNode2を削除していますが、配列のように削除したindex以降の要素をずらすという操作は必要ありません。

Node1が持っていたNode2へのポインタをNode3へのポインタに付け替えるだけで削除の操作は完了します。

このように、頻繁に削除や挿入が行われるケースは配列よりも連結リストの方が適しています。

可変長

連結リストは可変長のデータ構造です。

配列と違い、データを連続して配置する必要はないので、拡張が必要な場合は都度空いているメモリにNodeを割り当て、連結リストを拡張することができます。

動的なデータ長を扱う場合、配列よりも連結リストの方が適したデータ構造と言えます。

比較表

最後にそれぞれの特徴をまとめた表を置いておきます。

特徴/操作 配列 連結リスト
メモリの配置 連続 非連続
ランダムアクセス O(1)(高速) O(n)(低速)
先頭や中間への要素の挿入・削除 O(n)(低速、要素をずらす必要がある) O(1)(高速、ポインタを更新するだけ)
メモリ使用効率 低い(固定長) 高い(可変長)
サイズ変更 困難/不可能(再確保とコピーが必要) 容易(要素の追加・削除でサイズ変更)

線形探索

線形探索について

今回の主題である、連結リストと配列において線形探索する時に、どちらの方が計算コストが少ないのかという問題を考える前に、線形探索について軽く触れておこうと思います。

線型探索は検索のアルゴリズムの1つで、 連結リストや配列に入ったデータに対する検索を行うにあたって、 先頭から順に比較を行いそれが見つかれば終了するといったアルゴリズムです。

優秀なアルゴリズムですが、先頭から片っ端に探していくので「馬鹿サーチ」とも呼ばれているみたいです。(ひどい…😭)

ちなみに連結リストの節で

n番目の要素にアクセスするためには、連結リストの先頭から、指定された位置に達するまで各要素のポインタを逐次たどる必要があります。

このように、ランダムアクセスではなく先頭から逐次辿る必要があると述べましたが、まさにこれが線形探索となります。

どちらの方が計算コストが低いのか

線形探索自体の時間計算量はO(n)となりますが、実際の実行速度はデータ構造によって異なります。

配列と連結リストでは、どちらの方が速く探索できるのかを、実際にコードを書いて検証してみます。

なお今回はC++を使うことにします。

#include <iostream>
#include <forward_list>
#include <random>
#include <algorithm>
#include <chrono>
#include <vector>
#include <numeric>
#include <cmath>

// Generate random data for testing
template<typename Container>
void generate_data(Container &c, size_t size) {
    std::random_device rd;
    std::mt19937 mt(rd());
    std::uniform_int_distribution<int> dist(1, 1000000);

    c.resize(size);
    std::generate(c.begin(), c.end(), [&mt, &dist]() { return dist(mt); });
}

template<typename Container>
void linear_search_test(const Container &c, const std::string &type) {
    const int repeat_count = 200;
    std::vector<long long> times;
    
    for (int i = 0; i < repeat_count; ++i) {
        auto start = std::chrono::system_clock::now();

        // Perform a dummy operation to prevent loop optimization
        int sum = 0;
        for (const auto &val: c) {
            sum += val;
        }

        auto end = std::chrono::system_clock::now();
        auto elapsed = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
        times.push_back(elapsed.count());
    }

    double mean_time = std::accumulate(times.begin(), times.end(), 0.0) / repeat_count;
    double sq_sum = std::inner_product(times.begin(), times.end(), times.begin(), 0.0);
    double stdev_time = std::sqrt(sq_sum / repeat_count - mean_time * mean_time);
    double cv = stdev_time / mean_time * 100;

    std::cout << "Type: " << type << "\n";
    std::cout << "Average time: " << mean_time << "μs\n";
    std::cout << "Time stddev: " << stdev_time << "μs\n";
    std::cout << "Coefficient of variation: " << cv << "%\n\n";
}

int main() {
    const size_t data_size = 10000000;

    std::vector<int> vec;
    std::forward_list<int> list(data_size);

    generate_data(vec, data_size);
    generate_data(list, data_size);

    linear_search_test(vec, "Vector");
    linear_search_test(list, "Forward List");

    return 0;
}

これは、1000万個の要素を持つ連結リストと配列に対して、線形探索を200回ずつ行いそれぞれにかかった時間を計測するコードです。

結果の前に、計測するにあたって考慮した点を軽く紹介していこうと思います。

配列はarrayではなくvectorを使う

今回は、配列の表現に、std::arrayではなくstd::vectorを使うようにしました。

これは、連結リストと同じメモリ領域を使うためです。

std::arraystd::vectorはどちらも配列を表すデータ構造ですが、使われるメモリ領域が異なります。

std::arrayはコンパイル時にサイズが確定するのでスタック領域に割り当てられますが、

std::vectorはヒープ領域に割り当てられます。

それぞれのメモリ領域に軽く触れておくと :

  • スタック領域は、通常は関数の呼び出しや局所変数の保存に利用されるメモリ領域で、アクセス速度が速い
  • ヒープ領域は、動的にデータを割り当てる際に使用されるメモリ領域で、メモリアクセスの速度はスタックに比べて遅い

となっています。

この検証では、「メモリ領域の違い」ではなく「メモリの連続性」に焦点を当てています。

なので、配列と連結リストで使用するメモリ領域を揃えることで、メモリの連続性による影響を強調できるようにしました。

コンパイラがループを最適化しないようにした

線形探索に該当するループの部分でsumという変数に加算を行っています。

これは、何もループ内に処理を書かなければ、この部分の実行はする必要がないとコンパイラに判断されその部分は省略(最適化)されてしまうため、最適化されないようにダミーの処理を追加しています。

結果

こちらが検証に使用したマシンのスペックです。

  • CPU: Apple M2 Max
  • RAM: 64GB
  • OS: Ventura 13.4

このマシンでそれぞれ200回実行した結果は次のようになりました。

Type: Vector
Average time: 47886.5μs
Time stddev: 1139.39μs
Coefficient of variation: 2.3794%

Type: Forward List
Average time: 60295.2μs
Time stddev: 1518.22μs
Coefficient of variation: 2.5180%

結果から明らかなのは、配列(Vector)と連結リスト(Forward List)の平均実行時間には約12,000マイクロ秒の差があるという点です。また、両方のデータ構造における係数の変動(Coefficient of Variation)が低いことから、このデータは一定の信頼性を持っていると判断できます。

(マシンやリソースの状況によって結果は異なりますが)

では、なぜ時間計算量が両方ともO(n)であるにも関わらず、このような実行時間の差が生まれるのでしょうか?

これは、データのメモリ配置に関わる「キャッシュ効率」が大きな要因の1つとして挙げられます。

連続したメモリ領域に配置された配列は、キャッシュメモリにおいて高いヒット率を示すため、全体として計算コストが削減されるのです。

なぜ配列の方がヒット率が高くなるのでしょうか?

ここで登場したキャッシュメモリが持つこれらの性質と、なぜヒット率が異なるのかを次の章で詳しく見ていきます。

キャッシュメモリ

キャッシュメモリの概要

データAをメインメモリからフェッチするときの簡略図

キャッシュメモリは、CPU(処理装置)とメインメモリ(主記憶装置)の中間に位置する、高速なデータアクセスが可能な小さな記憶装置です。

ノイマン型アーキテクチャのコンピュータでは、CPUとメインメモリの処理性能には性能差があります。

いくらCPUの性能を高めようと、そのCPUの処理性能にメインメモリのアクセス速度が追いつけません。 そのため、メインメモリからデータをフェッチする速度の遅さがボトルネックとなり、システム全体の処理速度も遅くなります。

この現象は「ノイマンボトルネック」と呼ばれています。

キャッシュメモリは、両者の処理性能差を埋め合わせることでこの問題の解決を図ります。

具体的には、処理装置がアクセスしたデータやアドレスなどをキャッシュメモリに一時的に保持しておくことで、次に同じデータやアドレスにアクセスがあった場合、メインメモリではなくキャッシュメモリから迅速にデータをフェッチできます。

これにより、メインメモリへのアクセスが削減され、データフェッチの効率の向上、高速化が見込めます。

キャッシュメモリはL1,L2,L3といった複数のレベルを持っています。

記憶階層において、このレベルの数字が小さいほどCPUに近く高速になります。

補足 : 記憶階層とは?

記憶階層の簡略図

CPUにとって、望ましい記憶装置は大容量で高速にアクセスできるものです。

しかし、実際の記憶装置は大容量であるほどアクセス速度が遅くなり、小容量であるほど高速になるというトレードオフがあるので、望むような記憶装置は現状作ることができません。

そこで図のように記憶装置を階層化し、各階層を下位の階層のキャッシュとして用いることで、CPUから見た場合、あたかも大容量で高速な1つの記憶装置であるかのように振舞います。

このように階層化された記憶システムを、記憶階層と言います。

なぜ配列の方がヒット率が高いのか

配列が連結リストと比べてキャッシュメモリのヒット率が高くなる理由は、その連続したメモリ構造とキャッシュメモリのデータ取得方法に密接な関連があります。

キャッシュメモリは「キャッシュライン」という一定の単位でデータを一時的に保持しています。

キャッシュラインのサイズは、プロセッサのアーキテクチャやモデルによって異なりますが、32、64、または128バイトが一般的です。

メインメモリからデータAをフェッチする際、キャッシュメモリはデータAだけでなく、その近傍のデータも合わせて保持します。したがって、次に近傍のデータにアクセスする際はヒットする確率が高くなります。

array[0]のデータをフェッチする簡略図

例として、上図のarray[0]の要素をフェッチする場合を考えてみます。

array[0]をメモリからフェッチする際、その近傍のデータもキャッシュライン(ここでは例として32バイト)としてキャッシュメモリに一時保存します。

すると、4つ全ての要素がキャッシュメモリに保持され、次にこれらの要素にアクセスする際は、メインメモリよりも高速なキャッシュメモリからデータをフェッチすることができます。

これは、配列のメモリ上での連続した配置と、キャッシュメモリがキャッシュライン単位で近傍のデータも一緒に保存する性質とが組み合わさって実現されます。

一方で、連結リストではメモリ上に非連続に配置されているため、キャッシュミスが配列に比べて頻発します。

これが、配列と連結リストの間でキャッシュヒット率が異なる理由です。

また、このキャッシュラインによる近傍データの同時フェッチの考え方は、「空間的局所性」というコンピューティングの性質に基づいています。

空間的局所性

空間的局所性は、一度アクセスされたデータの近傍が近いうちに再びアクセスされる可能性が高い、というプログラムの実行特性を指します。

キャッシュメモリの設計において、キャッシュラインがこの特性に基づいています。

具体的には、一度データをフェッチすると、その近くにあるデータも一緒にキャッシュに取り込むことで、次のアクセスが高速になる可能性が高くなります。

時間的局所性

時間的局所性は本例では触れられていませんが、こちらも重要な特性ですので補足として紹介しておきます。

時間的局所性とは、一度アクセスされたデータが近いうちに再度アクセスされる可能性が高いという特性を指します。

webブラウザにおいて、「戻る」ボタンを押す行動もこの一例となります。

ブラウザは、この操作を高速に行うため、以前アクセスしたページのデータをキャッシュしており、これは時間的局所性に基づいたものになります。

おわりに

結論

配列が連結リストと比較して、線形探索の際のメモリアクセスのコストが低いことがわかりました。

この違いは、配列のほうがキャッシュメモリに対して高いヒット率を示すためです。

キャッシュメモリが高いヒット率を持つ背後には、キャッシュメモリが「空間的局所性」の原理に基づいて設計されていること、及び配列のデータがメモリ上で連続して配置される特性があります。

具体的には、キャッシュメモリはキャッシュラインと呼ばれる単位で近傍のデータも一緒に保持し、これによって近くのデータにアクセスする際もキャッシュから高速にデータを取得することができるので、結果的に配列の線形探索の方が早くなったのでした。

このようにハードウェアの特性や概念を知ることで、コンピュータリソースをより効率的に扱えるようになることにつながります。

Web開発においては、「業務で使わないから」という理由で低レイヤーやアルゴリズムの勉強が軽視されることがありますが、こういった抽象化されている部分を勉強することは、

普段使っている技術への理解が深まりスキルの向上につながると思うので、引き続きこういった内容のブログを発信していこうと思っています!

また、キャッシュメモリは他にもデータの一貫性を保つための「キャッシュコヒーレンシ」など、面白い仕組みがまだまだあるので、機会があればこちらもまた書いていこうと思います〜!

コネヒト株式会社は PHP Conference Japan 2023 にゴールドスポンサーとして参加しました!当日の様子をご紹介します!

こんにちは @ryoです!

コネヒトは今回 PHP Conference Japan 2023にゴールドスポンサーとして協賛してブースを出展させていただきました。
本ブログでは当日の様子や感想などをご紹介していきたいと思います!

PHP Conference Japan 2023 とは

PHP Conference 2023 は2023年10月8日(日)に開催された国内最大級のPHPイベントです!

phpcon.php.gr.jp

ブースの出展

今回コネヒトはブースを出展させていただきました。
ブースの内容は以前ブログで紹介したママリドリルとシステム開発に関するアンケートです。

tech.connehito.com

ママリドリルやアンケートにお答えいただいた方には、ノベルティとしてママリオリジナルデザインのチロルチョコをお渡ししました!

今年のPHPerKaigi 2023でブースを出展していた事もあり前回の反省点や経験を活かしスムーズに事前準備を進める事ができました。

当日の様子

当日は大田区産業プラザPiOで行われました。
スポンサーブースを出展する場所とセッションを発表する場所が一体となった空間だったのでとても広かったですが、全てのスポンサー様のブースが一箇所に集まっていて準備の段階で熱気が高くとてもワクワクしました!!

そして開場してからはたくさんの方がコネヒトのブースに来てくれました。本当にありがとうございます!

ブースにておもてなしをするコネヒトメンバー

ブースに来ていただいた参加者の皆様とママリついてのお話しや現在のシステム開発の事についてお互いの事情などをたくさんお話しさせていただくことができました!
他にもコネヒトメンバーはセッションを聞きにいったり他のスポンサーブースへお邪魔したりとても有意義な時間を過ごすことが出来て楽しかったです!

そして皆様から回答を頂いたアンケート結果はこちらです。

使っているPHPの主なバージョンは?

  • 8.2.x系 47票
  • 8.x 71票
  • 7系 74票
  • 5系 21票

好きなエディタは?

  • Visual Studio Code 106票
  • PhpStorm 81票
  • Vim 25票
  • Emacs 5票
  • サクラエディタ 10票
  • 秀丸エディタ 8票
  • その他7

個人的には好きなエディタはPhpStormがトップになるかなと予想していましたが汎用性抜群のVisual Studio Codeがトップになりました!Visual Studio Code強し。

感想

今回もたくさんの参加者の皆様と交流することができ満足したカンファレンスとなりました!!
これからもコネヒトはPHPコミュニティへの貢献を続けていきたい思います!
@ryo個人の感想としては今回のPHP Conference Japan 2023にプロポーザルを出したのですが残念ながら不採択だったので次回はリベンジできるように頑張りたいと思います!

【告知】コネヒトはPHP Conference Japan 2023にゴールドスポンサーとして協賛します!

こんにちは!高谷です。普段は主にPHPを書いています。

本日は弊社が参加するPHP Conference Japan 2023にスポンサーとして参加するお知らせです。

コネヒトはPHP Conference Japan 2023に協賛いたします!

コネヒトではメインプロダクトである「ママリ」を始めとして開発のメイン言語としてPHPを活用しており、フレームワークとしてはCakePHPを採用しています。
そんなPHPを愛用しているコネヒトですがこの度ゴールドスポンサーとして協賛させていただくことになりました。

phpcon.php.gr.jp

イベント概要

ブースを出展します!

今年はコネヒトは企業ブースも出します! 企画としては

1. あなたは何問正解できる!? ママリドリル

(ママリドリルについてはこちらからどうぞ)

コネヒト株式会社は PHPerKaigi 2023 にシルバースポンサーとして参加しました!スポンサーブース準備と当日の様子をご紹介します! - コネヒト開発者ブログ

2. システム開発に関するアンケート

を予定しております!

ママリドリルとアンケートにお答えいただくとノベルティのチロルチョコがもらえます! 可愛いチロルチョコになっているので、ぜひぜひご参加ください🙌

最後に

コネヒトでは共に開発を進めてくれる頼れるPHPerを積極募集中です!

hrmos.co

SOCIインデックスによるECSのデプロイ時間短縮について検証しました

こんにちは。2023年7月に入社しました、開発部プラットフォームグループ インフラエンジニアの @yosshi です。今回はSOCIインデックスによるECSのデプロイ時間の短縮効果について検証を行ったので、その検証内容と結果を共有したいと思います。

デプロイ時間短縮の方法は様々あると思いますが、SOCIインデックスはイメージやデプロイフローに手を入れずに、比較的簡単に取り入れられそうだったので導入を検討しました。

SOCI(Seekable OCI)インデックスとは

SOCIインデックスとは、イメージの遅延読み込み(非同期読み込み)を可能にする技術です。イメージ全体をダウンロードする前にコンテナイメージから個々のファイルを抽出できるようにし、コンテナを高速に起動することができます。

既存のコンテナイメージにあるファイルのインデックス (SOCI インデックス) を作成することによって機能します。

利用条件

  • コンテナイメージがx86_64もしくはARM64アーキテクチャであること
  • Linuxプラットフォームバージョンが1.4.0であること

参考記事

SOCIインデックスの適用方法

SOCIインデックスを作成する方法は、公式で用意されているAWS SOCI Index Builderを使用する方法と、手動で作成する方法の2パターンがありますが、今回はより簡単に導入できそうなAWS SOCI Index Builderを使用する方法を選択しました。

AWS SOCI Index Builderは、 AWS クラウド内のコンテナイメージのインデックスを作成するためのサーバーレスソリューションで、公式よりCloudFormaitonテンプレートが用意されています。

今回はこのCloudFormationテンプレートを利用し、Terraformで適用していきます。
以下のようなイメージです。

resource "aws_cloudformation_stack" "soci_index_builder" {
  name         = "soci-index-builder"
  template_url =  "https://aws-quickstart.s3.us-east-1.amazonaws.com/cfn-ecr-aws-soci-index-builder/templates/SociIndexBuilder.yml" <span style="color: #d32f2f">#公式で用意されているSOCIのテンプレートのパス</span>
  capabilities = ["CAPABILITY_IAM"]

  parameters = {
    "SociRepositoryImageTagFilters" = "<リポジトリ名>:<タグ名>" #SOCIインデックスの適用範囲のフィルターをかける
  }
}

AWS SOCI Index Builderのアーキテクチャ

EventBridge・Lambda・IAM・CloudWatchなどのリソースが作成されます。
引用: https://aws-ia.github.io/cfn-ecr-aws-soci-index-builder/

SOCIインデックス作成の流れ

  1. ECRイメージアクションイベントを検出しフィルタリング用のAWS Lambdaを呼び出す。
  2. CloudFormationのパラメータで提供されたフィルタに一致するイメージアクションイベントをフィルタリング。
  3. 一致するイメージのSOCIインデックスを生成し、ECRレジストリのイメージリポジトリにインデックスをプッシュバックする。

作成される各リソースの詳細を知りたい方は引用元のリンク先をご確認ください。

検証方法

SOCIインデックスの検証にあたり、今回2段階で実施しました。

  1. SOCIインデックスの効果検証

    • 実際のDev(開発)環境に適用する前に、まずはSOCIインデックスに本当に効果がありそうか調べました。
    • 現行のDev環境と同一のECSクラスター内にSOCIインデックス適用済みの別サービスを立て現行と起動時間の比較をしています。
  2. 現行の開発環境での動作検証
    • 検証1でSOCIインデックスの一定の効果が見られたので、実際に使用している開発環境にSOCIインデックスを適用し比較しました。

それでは検証内容について詳しく説明していきます。

前提条件

弊社では以下環境を使用しています 。

  • ECS on Fargate
  • AWSリソースの反映:Terraform
  • ECSのデプロイ:ecspresso

検証1:SOCIインデックスの効果検証

まずはDev環境の同一クラスター内にSOCIインデックス適用済み別サービスを立てて現行との起動時間を比較しました。

リソースの作成はTerraformで行っています。まずは必要なリソースを作成していきます。

ECRリポジトリ

resource "aws_ecr_repository" "example_soci_test" {
  name = "example-soci-test"

  image_scanning_configuration {
    scan_on_push = true
  }
}

SOCI Index BuilderのCloudFormationテンプレート
example-soci-testリポジトリの全てにSOCIインデックスを適用するようフィルターを設定しています。

resource "aws_cloudformation_stack" "soci_index_builder" {
  name         = "soci-index-builder"
  template_url =  "https://aws-quickstart.s3.us-east-1.amazonaws.com/cfn-ecr-aws-soci-index-builder/templates/SociIndexBuilder.yml"
  capabilities = ["CAPABILITY_IAM"]

  parameters = {
    "SociRepositoryImageTagFilters" = "example-soci-test:*"
  }
}

Terraformを使用し作成したリソースを適用します。

$ terraform apply

次に作成したECRリポジトリにイメージをプッシュします。

Dockerfileは従来のDev環境と同じものを使用しています。

イメージのビルド

$ docker build . -t example-soci-test:latest

リポジトリにイメージをプッシュできるように、イメージにタグ付け

$ docker tag example-soci-test:latest xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/example-soci-test:latest

ECRにイメージをプッシュ

$ docker push xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/example-soci-test:latest

AWSコンソール上でECRの対象リポジトリの画面から、作成したイメージに対して、アーティファクトタイプがSoci Index, Image Indexのイメージが別途作成されていることがわかります。この作成されたSOCIインデックス適用済みのイメージを使用します。

次にこのSOCIインデックス適用済みのイメージを使用しECS環境へデプロイします。

弊社ではデプロイツールとしてecspressoを使用しているため、ecspressoを利用しローカルからデプロイしたいと思います。(ecspressoを使用していない場合は別の方法でのデプロイを実施してください)

ecspresso initで既存のDev環境の設定ファイルをローカルに持ってきます。

$ ecspresso init \
--config config.yaml \
--region ap-northeast-1 \
--cluster <Dev環境のクラスター名> \
--service <Dev環境のサービス名>

実行することでローカルに3つのファイルが作成されます。

  • config.yaml
  • ecs-service-def.json
  • ecs-task-def.json

作成されたファイルの内容を今回テストする内容に書き換えます。

  • config.yaml
region: ap-northeast-1
cluster: example-cluster #Dev環境と同じクラスターを使用
service: example-soci-test-service #SOCIインデックス適用するためのサービス
service_definition: ecs-service-def.json
task_definition: ecs-task-def.json
timeout: "10m0s"
  • ecs-service-def.json
{
  "capacityProviderStrategy": [
    {
      "base": 1,
      "capacityProvider": "FARGATE_SPOT",
      "weight": 1
    }
  ],
  "deploymentConfiguration": {
    "deploymentCircuitBreaker": {
      "enable": false,
      "rollback": false
    },
    "maximumPercent": 200,
    "minimumHealthyPercent": 100
  },
  "deploymentController": {
    "type": "ECS"
  },
  "desiredCount": 1,
  "enableECSManagedTags": false,
  "enableExecuteCommand": false,
  "healthCheckGracePeriodSeconds": 0,
  "launchType": "",
  "networkConfiguration": {
    "awsvpcConfiguration": {
      "assignPublicIp": "ENABLED",
      "securityGroups": [
        <security-group> #現行で使用しているセキュリティグループを利用
      ],
      "subnets": [
        <subnet> #現行で使用しているsubnetを利用
      ]
    }
  },
  "pendingCount": 0,
  "platformFamily": "Linux",
  "platformVersion": "LATEST",
  "propagateTags": "NONE",
  "runningCount": 0,
  "schedulingStrategy": "REPLICA"
}
  • ecs-task-def.json
{
  "containerDefinitions": [
    {
      "cpu": 0,
      ],
      "essential": true,
      "image": "xxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/example-soci-test:latest",
      "name": "example-soci-test",
    }
  ],
  "cpu": "256",
  "executionRoleArn": <execution-role-arn>, #現行で使用しているexecution roleを利用
  "family": "example-soci-test-task",
  "ipcMode": "",
  "memory": "512",
  "networkMode": "awsvpc",
  "pidMode": "",
  "requiresCompatibilities": [
    "FARGATE"
  ],
  "taskRoleArn": <task-role-arn> #現行で使用しているtask roleを利用
}

ファイルの作成が完了したら、ローカルからecspressoで適用していきます

$ ecspresso deploy --config <config.yamlのパス>

反映されたことが確認できたら、環境の準備が整っているので起動のためのテストしていきます。

以下のシェルスクリプトを用意し、現在のDev環境とSOCIインデックス適用済みの環境でそれぞれ実行することで、どの程度起動時間に差があるのか確認します。

CLUSTER=example-cluster #現行のDev環境のクラスター
TASKDEF={テスト対象のタスク定義}
REGION=ap-northeast-1
NUM_OF_TASKS=1
TASKS=$(aws ecs list-tasks \
    --cluster $CLUSTER \
    --family $TASKDEF \
    --region $REGION \
    --query "taskArns[:${NUM_OF_TASKS}]" \
    --output text)

aws ecs describe-tasks \
    --tasks $TASKS \
    --region $REGION \
    --cluster $CLUSTER \
    --query "tasks[] | reverse(sort_by(@, &createdAt)) | [].[{startedAt: startedAt, createdAt: createdAt, taskArn: taskArn}]" \
    --output table

実行結果

SOCIインデックス適用前(現行のDev環境):1分05秒

---------------------------------------------------------------------------------------------------------------------
|                                                   DescribeTasks                                                   |
+-----------+-------------------------------------------------------------------------------------------------------+
| createdAt |  2023-08-14T12:39:13.360000+09:00                                                                     |
| startedAt |  2023-08-14T12:40:18.763000+09:00                                                                     |
|  taskArn  |  arn:aws:ecs:ap-northeast-1:xxxxxxxxxx:task/example-cluster/xxxxxxxxxxxxxxxxxxxxxxxx                  |
+-----------+-------------------------------------------------------------------------------------------------------+

SOCIインデックス適用済みの環境:36秒(29秒の短縮)

---------------------------------------------------------------------------------------------------------------------
|                                                   DescribeTasks                                                   |
+-----------+-------------------------------------------------------------------------------------------------------+
| createdAt |  2023-08-14T19:42:05.469000+09:00                                                                     |
| startedAt |  2023-08-14T19:42:41.185000+09:00                                                                     |
|  taskArn  |  arn:aws:ecs:ap-northeast-1:xxxxxxxxxx:task/example-cluster/xxxxxxxxxxxxxxxxxxxxxxx                   | 
+-----------+-------------------------------------------------------------------------------------------------------+

30秒弱起動時間が短縮されたことがわかります。

検証2:現行のDev環境にSOCIインデックスを適用

上記のテストである程度効果がありそうなことがわかったので、次に実際にDev環境に対してSOCIインデックスを適用していきます。

弊社ではブランチ環境をDev環境にデプロイする際、branch_deployのイメージタグを使用しています。 よって今回はbranch_deployのイメージタグを使用している全ての対象に対してSOCIインデックスを適用していきます。

再度Indexbuilderのフィルターに今回の対象を追加していきます。

resource "aws_cloudformation_stack" "soci_index_builder" {
  name         = "soci-index-builder"
  template_url =  "https://aws-quickstart.s3.us-east-1.amazonaws.com/cfn-ecr-aws-soci-index-builder/templates/SociIndexBuilder.yml"
  capabilities = ["CAPABILITY_IAM"]

  parameters = {
    "SociRepositoryImageTagFilters" = "example-soci-test:*, *:branch_deploy" 
  }
}

Dev環境での検証は、Github Actionsのデプロイ時間をもとに計測していきます。

  • Github Actionsの内容
    ※ 弊社で適用している内容から必要な項目のみ抜粋して記載しています。
name: Manually deploy to development

on:
  workflow_dispatch

env:
  IMAGE_TAG: ${{ (github.ref == 'refs/heads/main' && github.sha) || 'branch_deploy' }}
  AWS_ROLE_ARN: <aws_role_arn>

permissions:
  id-token: write
  contents: read
  actions: read
  issues: write
jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      ref_link: ${{ env.REF_LINK }}
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          role-to-assume: ${{ env.AWS_ROLE_ARN }}
          aws-region: ap-northeast-1
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1
      - name: Build, tag, and push image to Amazon ECR
        if: github.ref != 'refs/heads/main'
        env:
          DOCKER_BUILDKIT: 1
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: <リポジトリ名>
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG --build-arg GITHUB_ACCESS_TOKEN=${{ secrets.MACHINE_USER_GITHUB_ACCESS_TOKEN }} .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
      - name: Build, tag, and push image to Amazon ECR for main
        if: github.ref == 'refs/heads/main'
         # branch_depoloyタグ以外の処理が書いてあるので省略
         # ...

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          role-to-assume: ${{ env.AWS_ROLE_ARN }}
          aws-region: ap-northeast-1
      - uses: kayac/ecspresso@v2
        with:
          version: v2.0.3
      - name: Deploy to Amazon ECS
        run: |
          ecspresso deploy --config <configファイルのパス>

検証結果

弊社のとあるアプリケーションを例に、SOCIインデックス適用前と後でそれぞれ直近8回のデプロイ時間を計測し比較しました。

SOCIインデックス適用前のデプロイ時間

1 2 3 4 5 6 7 8
3分44秒 3分14秒 3分50秒 2分40秒 3分53秒 4分14秒 3分47秒 4分18秒

SOCIインデックス適用後のデプロイ時間

1 2 3 4 5 6 7 8
3分12秒 2分37秒 3分13秒 3分02秒 3分27秒 2分55秒 2分58秒 2分35秒

※ Github Actions内の”deploy”部分の時間を記載しています。

8回分のデプロイ時間を平均すると以下のようになりました。

  • SOCIインデックス適用前:3分42秒
  • SOCIインデックス適用後:2分59秒
  • 短縮した時間:43秒

時間のばらつきはあるものの、平均すると上記の通りデプロイ時間を短縮できたことになります。

実際のDev環境でも一定の短縮効果があることがわかりました。

補足

Lambdaのランタイムについて

  • 公式のCloudFormationテンプレートにより作成されるLambdaのランタイムがGo.1.xを使用しており、このサポートが2023年12月31日に終了するので、この点に関しては注意が必要そうです。
  • 弊社でも将来的に新しいランタイムへの移行を計画しています。

おわりに

  • 今回SOCIインデックスを試してみて、割と簡単に導入できた上に、一定の効果があったので、簡易的なデプロイ時間短縮の手段としてはアリだと思いました。
  • Dev環境での動作が問題なさそうなことがわかったので、順次本番環境への適用も検討していきたいと思います。
  • また一方で、なかなか大幅な改善とはならなかったので、改めて他の手段(Dockerイメージの改善等)も併せて検討していく必要があると感じました。
  • プラットフォームグループでは引き続きデプロイフローの改善をはじめ、様々な開発環境の改善に向け取り組んでいきたいと思います。

第3回 リーン開発の現場輪読会 リーン開発のテクニックや戦略についてワイワイ編

こんにちは!コネヒト歴ちょうど2年になったWebエンジニアの古市(@takfjp)です。最近は岩盤浴にハマっています。サウナは熱すぎて苦手なのですが、岩盤浴だとほどよい高温でじんわり汗をかきながら全身を温められてリラックスできるので、これで季節の変わり目を乗り切れそうです。

第3回輪読会

今回は、社内有志で実施している「リーン開発の現場 カンバンによる大規模プロジェクトの運営」の輪読会の様子をお届けします!第3回となる今回は書籍の第2部である「テクニックを詳しく見る」にフォーカスし、やってみたいことや内容についての感想をワイワイ語り合いました。 これまでの様子は以下の記事をご覧ください。

第1回 リーン開発の現場輪読会 技術課題についてワイワイ編 - コネヒト開発者ブログ

第2回 リーン開発の現場輪読会 プロセス改善や WIP についてワイワイ編 - コネヒト開発者ブログ

第1部では主に、著者が携わったプロジェクトにおいてリーン開発の手法をどう実践したか・それによってどんな良い効果や学びをもたらしたかについて書かれていますが、第2部ではこれまで書籍内で触れられたリーン開発に役立つ手法・テクニックについてメインに扱われています。 今回も参加者が事前に第2部17章〜21章を読み、Miroの付箋を使って「思ったこと」「あるある」「やってみたい」という3つの分類で気づきや感想についてワイワイ話し合いました。また、自分が気になった付箋にスタンプやコメントでリアクションするようにもしました。

輪読会でワイワイと話したこと

今回も上記のボードから特に話したいことをピックアップしたり、各自が気になった付箋について深掘りしつつ語り合いました。 第2部は主に使用する各種テクニックや戦略についてフォーカスして書かれているため、それらについてどう取り組むか?という議論が進んでいきました。

ペアプロやテスト駆動開発は学習コストが高そう。それによって得られる効果を増やすには?

  • テスト駆動開発は新機能ではなく、改修などで部分的に小さく始めていく。
  • ペアプロはハードル低いけどTDDは習慣にしていかないと身につかないかも。
  • 当たり前品質に高くコミットする人に誰かがなるか、外部から呼んでくる。
    • TDDに強い人から「どうやって強くなっていったのか」なども聞きたい。

テスト自動化戦略について

  • そもそもテストしづらい構成になっているパターンがあり、つらい。
    • 長大なControllerなど、動いているがテストを書きづらいコードをうまく分割していくところに辛さがありそう。
    • 機能やリポジトリ単位でテストを書きづらいコードが集中している。そこにみんなで力を合わせてテストを集中的に追加していくことで、その後の工程がスムーズになりそう。

プランニングポーカーの粒度について

  • メンバー間で見積もる際、ポイントの粒度で±1pt(ストーリーポイント)の認識を全員で合わせるのは難しく時間がかかる。
  • S、M、L程度の粒度であれば直感的に見積もることができそう。
  • 技術者間で時間単位で見積もると、1時間程度の認識のずれが生まれることもあるが、S, M, Lの粒度で十分そう。
  • 粒度に関わらず、プランニングポーカーを通して、タスクについての認識齟齬をなくしていくのは大事。

記入された付箋の数々

時間内で全てについて議論はできなかったものの、各章について以下のような意見・感想がそれぞれのメンバーから出たので紹介いたします。

第17章 アジャイルとリーンの概要

第18章 テスト自動化の戦略

第19章 プランニングポーカーによる見積もり

第20章 因果関係図

第21章 最後に伝えたいこと

最後に

本を実際に輪読する会は今回が最後でしたが、これまで話し合ったなかで良さそうだと感じた取り組みを組織内で実践してみて、どんな学びがあったか振り返る会が予定されています。次回はその振り返りの様子をブログでお届けいたします。 最後まで読んでいただきありがとうございました!

仮説→実験→検証→学び...プロダクト開発のループを実現するために行っていること

こんにちは! @TOC@takapy です。

最近は期の終わりも近づいてきたので、普段利用しているREALFORCEのキーボードを大掃除しました。めっちゃ綺麗になって気分も一新できたので、定期的にやらねばと思いました。いつもありがとうREALFORCE!

さて、今回はプロダクト開発においてMobius Outcome Deliveryの思想を取り入れた話をご紹介しようと思います。

私は7月にこのMobius Outcome Delivery研修を受け、実際にこの考えをプロダクト開発に取り入れてみることでプロダクト開発をする上で感じていた課題感を解消できるヒントになるのではないか、と思いました。

Mobius Outcome Deliveryとは何なのか、またその思想を実際にプロダクト開発においてどう取り入れたのかの具体的な事例について興味ある方の参考になれば幸いです。


目次


Mobius Outcome Deliveryとは

Mobius Navigator(https://www.mobiusloop.com/)

www.mobiusloop.com

Mobius Outcome Deliveryは価値を生み出すためのナビゲーターであり、戦略からデリバリーまでを繋ぎ、見えるようにするフレームワークです。「最小限のアウトプットで最大限のアウトカムを達成する」ことをゴールとしており、研修でも度々 アウトカム(価値) という単語が登場しました。 アイデアをいかにシンプルに小さく実験するか、を問いながら上図のようなメビウスの輪の形状をしたMobius Navigator(メビウスナビゲーター)に沿ってプロダクト開発を進めていきます。

プロダクト開発のループを大きくDiscover, Decide(Options), Deliverの3セクションで表現しており、プロダクト開発における羅針盤的なものになり得るフレームワークになっております。

弊社では「プロダクト開発は作って終わりではない」とよく言われています。Mobius Outcome Deliveryはその言葉を表現できるフレームワークである点が一番気に入っています。 ユーザーに届けて終わりではなく、届けた結果どうなったのか、そこから学びを得て次に何をやるのか、を意識できるようになっており、プロダクト改善におけるループを辿っていけるフレームワークとなっております。

当時の課題感、何がやりたかったのか

弊社では現在、アジリティが全社におけるキーワードとなっており、チームでもいかに小さく実験していけるか、が関心ごととして強くなっています。

その際に「仮説を立て、実験し、検証する」のサイクルを回しているのですが、下記のような課題感を感じておりました。

  • 各実験が点になっていて繋がっていないのではないか、繋がっていたとしてもそれが見えていないのではないか
  • なんとなく進みが遅いと感じるが、どこがボトルネックになっているのか特定がしづらいのではないか
  • やったことがチーム内に閉じていて、その知見をチーム間や将来の誰かのために活かせられてないのではないか

当時、プロダクトマネジメント本の輪読会を行ったメンバーとプロダクト開発におけるプロセスの改善を行おうと思ったときに課題感を擦り合わせたのですが、上記3点がほぼ同じ課題感として挙がっており、「仮説→実験→結果→学び→仮説 … のループを回したい、それを見える化したい」という共通の思いが一致したので、これを実現できる方法を模索することになりました。

解決に向けて、方法の模索

Mobius Outcome Deliveryが仮説→実験→結果→学び→仮説 … のループをうまく表現していると思ったので、この研修で得たものを共有し、どうやってこの思想を表現できるか、プロダクト開発に取り入れられるか、を考えてみました。

そこでこの思想をプロダクト開発に取り入れるためのツールとして、 Miro など色々な方法を模索したのですが、Notionのプロジェクト管理テンプレートが一番やりたいことを実現できるのではないか、と思いました。

www.notion.so

Notionのプロジェクト管理は1つのプロジェクトに対して複数のタスクが紐づく形で構成されています。

Notion Projectについて

これを弊社のプロダクト開発に応用して、1つのやりたいこと、達成したい価値に対して実験を紐づけることでやりたいことができるのではないか、と考えました。

実際に作成したものをもとにどう実現したのかを具体的に説明します。

Mobius Outcome Deliveryを実現するために、どうやってNotionを活用したのか

以下Mobius Outcome Deliveryを実現する上でポイントになる点に絞って説明していきます。

Discover:テーマを作成する

Discoverの道のり

まずは上図の左側、Discoverの部分です。ここでは「なぜやるのか、誰に向けてやるのか」といった問題設定を行い、それを解決した際に得られるアウトカム、を決定します。

この部分をNotion Projectsにおける プロジェクト として作成します。これを自分達は テーマ と呼ぶようにしました。

データベースから新規テーマを作成すると下記のようなテンプレートが表示されます。

テーマのテンプレート

このテンプレートを埋めていくことでMobius Outcome Deliveryの左側Discoverを辿っていく形式になります。

Options, Deliver:実験を作成する

Options, Deliverの道のり

実際にやりたいことが定まったら、実現するための実験を考えていきます。

どうやったら一番シンプルに簡単に実験できるかを考え、やることが決まったら実験を作成します。

実験の作成

実験を作成するボタンを押すと、このテーマに紐づいた実験が作成されます。

実験はテーマが紐づいた状態で作成されるので、この実験は何を実現するためのものなのか、といった紐付けが見えるようになります。

このドキュメントは弊社では実験ドキュメントと呼ばれており、どうやって実験するのか、この実験で得た学び、などを記載できるようになっています。

この実験ドキュメントについては後の章で詳しく説明します。

テーマと実験の紐付け

実験が終わり、学びを得たら、次の一手を決めていきます。

Decide:もっと作るのか、別の問題を見つけるのか

Decideの道のり

実験をしても必ずしもうまくいくとは限りません。やってみて実際に得た反応をもとにもっと作っていくのか、はたまた別の問題を見つけるのか、といった選択をする必要があります。

これはMobius Outcome DeliveryでいうDecideのフェーズであり、ここで分岐が発生します。 この次への繋がりが一番実現したかった部分であり、今回のこだわったポイントでもあります。

私たちはこの繋がりをNotionのリレーションを用いて表現しました。

1. もっと作るという判断をした場合

例えば実際にユーザーに使ってもらい反応をみた際に、ニーズはありそうだが、少し別の形で実装をしてみたい、と思ったとします。Mobius Navigatorでいうところの再度Createのループに入るイメージです。

もっと作る判断をした場合

この時は実験ドキュメントで「次の実験を作成する」ボタンを押します。

次につながる実験を作成する

これを押すと2回目の実験が作成され、 前の実験 プロパティに1回目の実験とのリレーションが作成されます。

このリレーションにより2回目の実験は何を根拠に生まれた実験なのか、の繋がりを表現できるようになりました

実験の繋がり

2. 別の問題を見つけるという判断をした場合

実際にユーザーに届けてみるとニーズがないことがわかったり、実験をすることで別の問題を見つけることもあります。次に繋がる実験だったということですね。

この時Mobius Navigatorでいうところの再度Discoverのループに入るイメージです。

別の問題を見つけた場合

テーマのドキュメントに戻るとネクストアクションが設定されています。ここで「次のテーマを作成する」ボタンを押すと、次に繋がるテーマが作成されます。

次に繋がるテーマを作成する

このリレーションが作成することで、テーマ間での繋がりも作成されます。

こうすることで下図のように1つ1つのループにおける繋がりも表現できるようになりました。

ループが繋がっていくイメージ

まとめると

テーマと実験の関係図をまとめると下記のようになります。

テーマと実験の関係図

また、一連の流れをMobius Navigatorのループに当てはめると下記のようになります。

Mobius Navigatorに沿ってやること

このようにNotionを利用することで、Mobius Outcome Deliveryのフローをできるだけ簡単に、かつテンプレートに沿っていくことで実現できるような仕組みを作成しました。

最終的に作成したものはコネヒトカンバンと命名し、PdMを中心に展開をしていきました。

コネヒトカンバン爆誕

補足: 実験ドキュメントについて

ここで、先程説明をスキップした実験ドキュメントの詳細について改めて紹介します。

上記で説明した「テーマ」に紐づく形で、「実験ドキュメント」が作成されます。

前述した通り、この実験ドキュメントは1つのテーマに対して複数紐づくものとなっています。また、実験同士の繋がり(前後関係)も表現することができます。

実験ドキュメントのテンプレートは、以前のブログでも紹介した「A/Bテスト標準化テンプレート」を土台として、少しブラッシュアップして作成しました。

tech.connehito.com

テンプレートに入れ込んだ最終的な項目は以下のようなものです。

  • 実験の背景
  • 実験の概要
  • モニタリング指標
  • 実験後のアクションプラン
  • 実験結果

以降ではブラッシュアップしたポイントを中心に紹介します。

ステータス管理を容易に

冒頭で述べた通り、課題感の1つに「なんとなく進みが遅いと感じるが、どこがボトルネックになっているのか特定がしづらいのではないか」というものがありました。

そこで

  • 各実験は今どのステータスなのか
  • ステータスが暫く変わっていないのであれば、何が原因で変わっていないのか

などの現状を知り、そこでボトルネックを共有して解決を図れるような仕組みにできるよう、ステータス管理を簡単に行えるようにしました。

Notionで以下のようなテンプレートを用意しておき、「開発開始」や「検証開始」などのボタンを押すだけで、ステータスが変更されるようにしました。(ボタンのロジックなどは後述)

ステータス変更を簡単にするためのボタン

テンプレートの末尾には、実験終了したタイミングでステータス更新 & 結果記入欄が表示されるボタンも追加しています。

実験が終了した際のボタン

実験のサマリを把握しやすく

実験ドキュメントは現在の状態を把握できるだけでなく「過去に何がうまくいって、何がうまくいかなかったのかを振り返ることができる」ドキュメントとしても効果を発揮します。

過去の実験を一覧で見たときに「どの実験がどんな目的で行われて、結果はどうだったのか」がパッと分かるとハッピーですよね。

そこで、実験のサマリをNotionのページプロパティに持たせることで、一覧で見たときに逐一ドキュメントの中身を開かずとも大まかな内容を知ることができるようにしました。

実験サマリプロパティのテンプレート

一覧で見るとこんな感じです(マスク部分が多くて分かりづらいかもしれませんが雰囲気だけでも感じていただければと)

実験一覧

カンバンを作成する上で工夫した点

以上が大まかなカンバンの仕組みと詳細になります。

今回このコネヒトカンバンを作成するにあたって、実際に使ってもらうようにする工夫をいくつか行ったのでご紹介します。

利用者はできるだけ流れに沿うだけで利用できるようにする

今回カンバンを作成するにあたって、とにかく流れに沿えば自動的にメビウスループに乗っているような設計を心がけました。

その際に役に立ったのがNotionのボタン機能です。

www.notion.so

この機能を使うことで、ボタンをクリックした時にプロパティの操作やページ作成などを自動で行ってくれます。

例えば「次のテーマを作成する」ボタンはクリックすると下記のような動作を自動でおこなってくれます。

操作するページのステータスを 完了 にする→繋がりを紐づけてページを作成する→新規ページを開く

ボタンを使ったプロパティの変更設定

このように利用者はボタンを押すだけで自然とループに乗っている状態を目指すことで利用側のハードルをできるだけ下げるようにしました。

目的や思想、使い方のドキュメントを用意する

今回カンバンを作るにあたって、目的や思想の設計を入念に行いました。

実際にこのカンバンを作った人はいいのですが、利用する人たちはやりたいことなどを理解しないと「これをやる目的ってなんだろう…」といった状態になってしまいます。

なので、このカンバンの目的やどういった思想のもとで作成しているのかを書いたドキュメントを用意しておきました。

カンバンの目的を書いたドキュメントの一部

ただ、用意しているだけでちゃんと読んでもらえるとは限りません。そこは読んでもらう工夫だったり、仕組み化を今後も考える必要があると思っています。

実際に運用してみてどうだったか

PdMを中心に実際に利用してもらい、数ヶ月が経ちました。実際に使ってみた感想をアンケートで取ってみたので一部ご紹介します。

全チームの状況が一箇所で見れるのが最高

ステータスが俯瞰でみれるのがけっこう好き

特に繋がりの部分はポジティブな意見が多かったです

前の繋がりがあるから文脈思い出しやすい(あーこのゴールに向かう施策ね〜的な)

前後の繋がりが圧倒的にわかりやすくなった!

みんな書いてるけど、つながりがみえるようになったのはとても良い!

嬉しい声もありつつ、下記のような課題感もあらためて発見できました。

説明されると理解できるが、自分だけだと構造や使い方を理解するのにハードルがあった

実験というワードが強く、1回きりの施策の時(実験じゃない場合)に書いていいのかちょい悩むことがある

前後のつながりが見えるようになったのはとても良いが、まだ運用して時間が経っていないので本当に効果的かは長い目で見たい

ある程度想定はしていたり、対策をしたつもりでしたが、やはりこの辺は難しいですね。

浸透に関しては辛抱強く、でも楽しみながらみんなで続けていって、より良いものになるようにしていきたいと思ってます!

今後の展望

このカンバンを導入して、約2ヶ月ほどが経過しました。 色々書きましたが、まだ道半ばであり実験的な取り組みです。

改めて自分達がやりたかったこと、達成したかったことができてるかと言われると、できつつあるけど、まだまだこれから、と言う状態です。なので、今後もブラッシュアップしていきたいと思っています。

例えば、実験で得た知見をPdMやチーム間で共有できるような仕組みを用意したり、もっと過去の繋がりを遡って追加したり…などなど。

やりたいことがたくさんありすぎ状態ですが、同時にワクワクもしているのでこの取り組みを今後も続けていって、Mobius Outcome Deliveryの知見と実践を積んでいきたいと思います。

並行処理と並列処理の違いをコンテキストから考える

こんにちは!バックエンドエンジニアのjunyaUと申します。

実は9月入社で入社してから1ヶ月も経っていない(執筆当時)のですが、テックブログを書かせていただけるのは本当にありがたい環境だな〜とヒシヒシと感じております。

最近、プライベートではもっぱら自作OSの開発をしており、時間があっという間に溶けてしまいご飯をちゃんと食べれていないのが悩みです。

今回は並行処理と並列処理の違いをコンテキストの観点から考えて、両者がどのように違うのかを考察していこうと思います〜!

はじめに

なぜ記事を書こうと思ったのか

数年前、私が学生だった頃に初めてGoを触り、そこで「並行処理」という言葉を初めて耳にしました。その言葉を初めて聞いたとき、「プログラムを同時に動かすことができるのか!」とワクワクしましたが、並行処理について調べてみると、

  • 実は高速に切り替えて実行しているだけで本当に同時に実行していない
  • 並列処理と並行処理があり、並列処理は本当に同時に実行している

というような内容が書かれており、当時は全く理解できずぼんやりとしたまま考えるのをやめてしまいました。

時は過ぎ、私はOSの自作やコンピューターサイエンスの勉強にハマりました。

そこで、プロセスの並行処理を調べたり自分で実装したりするうちに並行処理と並列処理への解像度が少し上がりました。

ネットで並列処理と並行処理の違いを調べてみても、この問題を考える上で重要なコンテキストやコンテキストスイッチの概念に触れている記事が少なかったので、今回はコンテキストの観点から両者の違いを考えていこうと思います。

並行処理と並列処理の違いの結論

結論から述べると、プロセスAとプロセスBの実行に際して、

  • 並行処理は、特にシングルコアの場合、1つのコアがAとBのコンテキストを高速に入れ替えながらプロセスを実行する方式
  • 並列処理は、複数コアを使って、AとBのプロセスを真に同時に実行する方式

となります。

この違いを理解するためには、コンテキストの概念の理解は不可欠です。

次節からコンテキストに焦点を当てて並行処理と並列処理の違いを見ていきます。

プロセスのコンテキストとは?

コンテキストの定義

コンテキストとは、プロセスの現在の実行状態を表す情報の集合を指します。

これは、プロセスが中断された後、その状態から継続して実行できるようにするための「状態のスナップショット」であると言えます。

実際には、プロセスの実行状態を保存するためのデータは様々ありますが、重要でわかりやすいデータとしては、以下のものがあります。

  • プログラムカウンタ : 次に実行する命令が格納されているアドレス
  • スタックポインタ : 変数や一時的な計算結果など、プログラムの実行に必要なデータが格納されているアドレス
  • フラグレジスタ:条件分岐や算術命令の結果に基づくフラグの値

これらのデータの多くがCPUのレジスタに格納されています。

なので、コンテキストとはレジスタの内容 と考えることができます。

では、このレジスタについて詳しく見ていきます。

レジスタとは?

CPUとメモリの簡略図

レジスタはCPUの内部に存在する、高速にアクセス可能である小さな記憶領域です。

CPUはメインメモリに直接アクセスするよりもレジスタへのアクセスの方が高速であるため、頻繁に使用されるデータや、実行中の命令の情報は一時的にレジスタに格納されます。

CPUの動作は、基本的に「命令のフェッチ」と「命令の実行」の繰り返しで、フェッチする際はメモリからデータや命令を取得し、レジスタに格納され、レジスタの値から処理が行われます。

このレジスタには先ほど述べた、プログラムカウンタやデータなどが格納されているわけですが、

もしプロセスAの実行中に、プロセスBのデータのアドレスやプログラムカウンタの情報でレジスタの内容を上書きした場合、どうなるでしょうか?

プロセスAの実行中にも関わらずいきなりプロセスBの実行に切り替わってしまいます。

これが、並行処理で行われていることの核心となる部分です。

次の節で詳しく見ていきます。

並行処理とは?

並行処理の定義

並行処理の定義は、「1つのCPUコアに対して複数のコンテキストを高速に入れ替えながらプロセスを実行する方式」と冒頭で述べました。

改めて、コンテキストにフォーカスして考えてみます。それぞれのプロセスはそれぞれのコンテキストを持っています。(メモリに格納されている場所やデータがそれぞれ違うので当たり前ですね)

メモリの簡略図(アドレスや配置は説明用なのでデタラメです)

上の簡単な図をもとに考えてみます。

プロセスAのコンテキスト

  • プログラムカウンタ: 0x1000
  • スタックポインタ: 0x3000

プロセスBのコンテキスト

  • プログラムカウンタ: 0x7000
  • スタックポインタ: 0x9000

プロセスAが実行され始めた時、レジスタにはプログラムカウンタの0x1000、スタックポインタの0x3000を始めとした様々な値が保存されています。

ここで、プロセスAの実行中に現在のレジスタの値をどこかに退避させ、プロセスBのコンテキストをレジスタに格納すると、プロセスAの処理が中断され、プロセスBに処理が切り替わります。

これを連続して高速に行うとどうなるでしょうか?

CPUから見ると、複数のプロセスを高速に1つずつ処理しているだけなのですが、

人間から見ると、プロセスAとプロセスBが同時に動いているように見えると思います。

これが並行処理なのです。

コンテキストスイッチ

コンテキストスイッチの簡略図

並行処理について調べていると「コンテキストスイッチ」というワードを目にすることがあると思います。当時の私にとってはこれが難しく理解できませんでした。

ですが、コンテキストスイッチの説明は既に上の節で述べられています。

プロセスAの実行中に現在のレジスタの値をどこかに退避させ、プロセスBのコンテキストをレジスタに格納すると、プロセスAの処理が中断され、プロセスBに処理が切り替わります。

この部分です。レジスタの値をプロセスAのコンテキストからプロセスBのコンテキストに切り替えるという処理がコンテキストスイッチとなります。

もう少し砕いた言い方をすると、コンテキストスイッチ = レジスタの値の入れ替え

ということがいえます。

また、高速にコンテキストスイッチを行うことが並行処理といえます。

並列処理とは?

並列処理の定義

並列処理の定義は、「並列処理は複数コアを使って、AとBのコンテキストをそれぞれ異なるコアに格納し、複数のプロセスを真に同時に実行する方式」と冒頭で述べました。

並行処理は1つのCPUコアが複数のプロセスを切り替えながら実行するものなので、実際には同時実行していませんが、並列処理は真に同時に実行しています。

なぜ並列処理は真に同時に実行することができるのでしょうか?

コンテキストとマルチコア

並列処理の真髄は、CPUが複数のコンテキストを同時に扱える能力にあります。

コンテキストの複数保持を実現してくれるのがマルチコアの存在です。

CPUコアはコアごとにそれぞれレジスタを持っており、複数のコンテキストを同時に保持、実行することができます。

プロセスAとプロセスBを並列実行している簡略図

並行処理はコア1つに対してAとBを同時に実行させようとしていたので、高速にAとBをコンテキストスイッチする必要があったのに対して、並列処理は複数のコアを用いるのでコンテキストスイッチをすることなくそれぞれのコアにコンテキストを保持させて真に同時に実行することができます。

まとめ

並行処理は1つのコアに高速にコンテキストを切り替えて実行していく方式で、

並列処理は複数コアが、同時に実行したいプロセスのコンテキストをそれぞれ保持して同時に実行をしていく方式でした。コンテキストからのアプローチで見ると思っていたより理解しやすいのではないでしょうか。

実際には、どちらの処理方式も単独で使用されることは少なく、一般的にはこれらを組み合わせて利用されます。今回は、それぞれの方式の懸念点やパフォーマンス上のトレードオフ、最適な使い所には触れていませんが、これらのテーマは非常に奥が深いので、機会があればまた書きたいなと思います。

なお、今回の説明はわかりやすさを重視して簡略化している点もあるため、その点をご了承ください。