TECH PLAY

株式会社エブリー

株式会社エブリー の技術ブログ

385

はじめに こんにちは。開発本部開発1部デリッシュキッチンMS1に所属している 江﨑 です。 今回は、User Matching(以下、UM)事業において、AIソフトウェアエンジニアである Devin を導入し、LP(ランディングページ)の運用タスクを自動化した事例について紹介します。 前提知識 Devinとは Devinは、Cognition AI社が開発したAIソフトウェアエンジニアです。コードの理解、実装、デバッグ、テストまでを自律的に行うことができ、開発者の作業を大幅に効率化します。GitHubとの連携により、Issueの内容を理解してPull Requestを作成することも可能です。 UMとは デリッシュキッチンやトモニテではプレゼントキャンペーンが定期的に行われており、ユーザー様はいくつかの質問にLP経由で回答することで応募できるようになっています。 この中で、エンジニアは、LP作成・変更、ETL処理の実装などを担当しています。 事業の特性上、クライアント様の要望に応じたLPを個別で作成しているため、LPの新規作成やCVR改善などのための既存のLPの修正が頻繁に発生します。 Devinの導入目的 具体的に、UM事業におけるLPの運用タスクは、画像の差し替えや文言修正、設問の追加・削除といったものが多くを占めていました。 これらは単純な定型作業ではなく、クライアント様ごとの要望に応じて個別カスタマイズが必要となる作業です。 一つ一つの難易度は高くないものの、数が多く、かつそれぞれ微細なカスタマイズが必要で、エンジニアの貴重なリソースを割いてしまっているという課題がありました。 そこで私たちは、これらのLP運用タスクを自動化し、開発工数の削減、エンジニアがよりクリエイティブな業務へ集中できる環境の構築を目指すため、Devinの導入を決定しました。 LP運用タスクの自動化 Asanaから実装までの流れ 弊社では、タスク管理ツールとして Asana を採用しています。 LP運用タスクは、ビジネス職の人がクライアント要望を踏まえて、Asanaにチケットをすることでエンジニアに開発依頼が渡ってきます。 そこで、Asanaから実装までを自動で行うようなフローを構築しました。 具体的な流れは以下のようになります。 トリガー :依頼者がAsanaチケットのカスタムフィールドを Devin実行 に変更します。 Webhook発火 :フィールド変更をトリガーにAsana Webhookが発火し、API Gateway経由でLambda関数を呼び出します。 Issue作成 :LambdaがGitHub APIを叩き、LPのリポジトリにIssueを自動作成します。 実装計画の立案 :Issueが作成されると、自動で devin-plan ラベルが付与されます。これをトリガーにGitHub ActionsでDevin APIを叩くことでDevinが起動し、Asanaの依頼内容を元に実装計画を練り、Issueのコメントとして投稿します。 計画のレビュー :エンジニアがDevinの作成した実装計画を確認します。もし修正が必要な場合は、Issueにコメントを投稿します。 実装開始 :計画の確認が終わったら、エンジニアがIssueに devin ラベルを付与します。これも devin-plan ラベルと同様にGitHub Actions経由でDevinが起動し、実装を開始します。 Pull Request作成 :Devinが実装を完了すると、自動でPRを作成します。 PRのレビューと修正 :エンジニアは生成されたコードをレビューします。修正が必要な場合は、PR上で @DevinAI とメンションすることで、Devinに修正を依頼できます。 このフローにより、エンジニアは「計画のレビュー」と「最終的なコードのレビュー」をするだけで良くなります。 実装のポイント 1. 段階的な実装アプローチ DevinにAsanaからPR作成までを全自動でやってもらうのではなく、実装計画をエンジニアがレビューするステップを設けました。これには以下の意図があります: 全く異なる計画を立てて実装を行なってしまった時の手戻りリスクを回避 Devinの1セッションのACU(Devinがタスクを実行するのに必要な計算リソースの単位)を削減し、コスト最適化 2. プロンプトエンジニアリング devin-planで実装計画を立てるときに、計画をIssueのコメントに投稿せずに、実装まで行なってしまうことが多々ありました。そこで、以下のようにDevinに行うタスクのゴールを明確にしたところ、動作が安定するようになりました。 **Issueにコメントとして投稿するのみで実装は行わないでください。** コメントとして投稿したらゴールです。 投稿した時点で、sleepしてください。 以下のIssueの内容を確認し、計画を立てて、Issueにコメントとして投稿してください。 Issue タイトル: (高-3)DK_xxx_開発依頼 Issue 本文: --- ## 📋 タスク詳細 xxx --- Issue URL: https://github.com/xxx/xxx/issues/xxx 計画には以下を含めてください: 1. 実装の概要 2. 必要な変更ファイルのリスト 3. 実装手順 4. 考慮事項やリスク システムアーキテクチャ この自動化フローは、Asana Webhook、AWS、GitHub APIを連携させて構築しています。 具体的なアーキテクチャは以下のようになっています。 AWS構成の詳細 API Gateway AsanaのWebhookリクエストを受信するRESTエンドポイントとして機能します。 Lambda (受信・検証) Webhookの認証やSQSへの転送を実行します: HMAC署名検証 : Asanaからの正当なリクエストかを検証 User-Agent検証 : 想定外のクライアントからのアクセスを排除 SQSへの転送 : 処理内容を非同期キューに送信 即座に200レスポンス : Webhookタイムアウトを回避 SQS FIFO Queue 1つ目のLambdaから処理内容を受け取り、2つ目のLambdaを起動する中継役として機能します。 Lambda (処理実行) SQSから受け取ったメッセージを基に、実際のビジネスロジックを実行します: カスタムフィールド確認 : Asanaタスクの特定フィールド値をチェック Issue内容成型 : GitHub Issue用のフォーマットに変換 GitHub API呼び出し : RESTful APIによるIssue作成とラベル付け 失敗から学んだ技術的工夫 初期実装での大失敗 最初の実装では、1つのLambda内でAsana Webhookの受信からGitHub Issue作成まで全て処理しようとしました。しかし、これが大きな問題を引き起こしました。 問題: Webhook タイムアウト AsanaのWebhookは10秒以内にレスポンスを返さないと失敗扱いになります。失敗すると、Asanaは24時間の間1時間ごとにリトライを繰り返します。 問題は、Lambdaでの全処理に10秒以上かかってしまったことでした。実際にはGitHub Issueの作成は成功しているのに、Asanaには失敗として認識され、Webhookのバックオフが発生してしまいました。 その結果、テスト用タスクが深夜に約200個のIssueとして自動生成されました。さらに、それらが devin-plan ラベルに反応して大量のDevinが起動し、60ACU(約2万円)分のコストを無駄に消費してしまいました。 解決策: 非同期処理による分離 これらの失敗を受けて、Lambda→SQS→Lambdaという2段階の非同期処理構成に変更しました。(詳細は上記「AWS構成の詳細」を参照) この構成変更により、Webhookのタイムアウト問題は完全に解決され、現在は安定して稼働しています。 結果と成果 導入効果 こちらの運用フローは、現在本番運用を開始したばかりのフェーズになります。 現在は、ビジネス職から依頼が来たAsanaのタスク説明をエンジニアが微修正して運用しています。 現時点でも、大量のLPタスクを並列で実装することができるようになり、エンジニアの負担が軽減されてきています。 今後の展望 今後、以下のような対応を進めることで、ビジネス職からの指示でも安定してLP運用タスクを行なっていけるようになれば、エンジニアの介入がさらに少なくなると考えられます。 Asanaへの記載フォーマットの整備 :テンプレート化による依頼品質の向上 Devinのknowledge強化 :うまく実装できないパターンのドキュメント化 また、弊社では現在、コードレビューAIツールの CodeRabbit と greptile の検証を行なっています。 今後、Devinによる実装とこれらのコードレビューAIを組み合わせることで、エンジニアの負担をさらに軽減できると期待しています。 まとめ 今回は、Devinを活用してLP運用タスクを自動化した事例をご紹介しました。 AIに実装を任せる場合は、タスクの粒度が非常に大切であり、今回のLP運用のタスクはDevinに与えるタスクの粒度として適切だと思いました。また、自動化を進める中で、以下のような学びがありました: 明確な指示の重要性 :誰が見ても同様の解釈ができるプロンプトの設計 適切な人間の介入 :全自動化ではなく、計画レビューとコードレビューという重要なポイントでエンジニアが介入する仕組み ナレッジの蓄積 :よくあるパターンや失敗事例、ドメイン知識をドキュメント化し、Devinの精度向上に活用 この記事が、皆さんのチームでのAI活用や業務改善のヒントになれば嬉しいです。 最後までお読みいただきありがとうございました。
アバター
はじめに こんにちは、リテールハブ開発部でバックエンドエンジニアをしているホシと言います。 現在、小売アプリの開発でLaravel11を利用してサービス開発を行っています。 今回は現在サービス提供している環境をマルチテナント化したお話をしようと思います。 既存の単一テナント前提のLaravelのサービスを、コンシューマ向けアプリと企業向け管理画面を維持したままマルチテナント化しています。 現在の構成をなるべく維持しつつ、最小改修・性能・運用の観点で2方式を比較しました。 比較した結果として、テナントごとに実行環境を分離する方式を採用しています。 本記事では、その判断基準とLaravel側の実装ポイントを少しサンプルコードも入れてお話できればと思います。 要件について ただ、マルチテナント化といっても様々な方法があると思いますが、 今回検討したマルチテナントは以下の要件で行いました。 APIサーバーはLaravel、Octane+Swoole、Auroraを使用。 リリース済みのサービスに、コンシューマ向けアプリと企業向け管理画面の二系統があり、テナント(企業)ごとにデータ分離したい。 現時点のコードは単一テナント前提。最小の変更で安全に分離しつつ、なるべくパフォーマンス劣化せずにOctane/Swooleのコネクション再利用リスクも避けたい。 また、本来はインフラ構成も非常に重要ですが話が広がりすぎるため、サーバー側のみにフォーカスします。 Octane/Swooleについて Octane/Swooleを使用するとリクエストごとにプロセスを初期化せず再利用することで処理が高速化することができます。 そのため、同じDB接続先であればメリットを大きく受けられますが、今回の様な接続先の切り替えを行うような処理があると、 以前の状態が残ったものがそのまま使用されることでバグを引き起こす場合があります。 そのため、接続/切断の処理対策を行う必要があります。 共通設計のポイント テナント判定:Host(サブドメイン)、X-Tenant-Idヘッダ、URLパスのいずれかで特定 → 今回はサブドメインで判定 データベース:テナント毎にデータベースを分離し、テーブル内で各テナントデータが混在しない方式にする セッション衝突回避:session.cookie名、session.domainをテナント別にする Swoole/Octane:接続の再利用に注意。purge/disconnectを「必ず」行い、クロスリクエスト汚染を防ぐ 2パターンの方式について 要件、共通設計のポイントを踏まえ、2パターンの方式を検討しました。 それぞれにメリット、デメリットがあるため、それぞれ詳細に見ていきたいと思います。 ① Middlewareで接続先判定+mysqlを書き換え リクエストごとにdatabase.connections.mysqlを書き換える方式 ② Ecspressoの設定をテナントごとに分け、タスク(実行環境)をテナント分離する方式 以下にサンプルコードを記載していますが、検討中に実装した検証コードのため、 動作保証できておりませんので参考程度にご覧いただけると幸いです。 ① Middlewareで接続先判定+接続設定(mysql)を書き換える方式(最小改修) 【全体の流れ】 Middlewareでテナントを判定する。(テナント判定は管理DBの判定用テーブルを用意する。) 該当テナントのDB接続情報を config(['database.connections.mysql' => …]) で上書き DB::purge('mysql') → API処理 → DB::disconnect('mysql')(Octane/Swooleで超重要) サンプル:テナント解決 & 接続切替 Middleware <?php // app/Http/Middleware/SwitchTenantConnection.php namespace App\Http\Middleware; use Closure ; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; class SwitchTenantConnection { public function handle ( $ request , Closure $ next ) { $ tenant = $ this -> resolveTenant ( $ request ) ; // 例: サブドメインやヘッダから判定 abort_unless ( $ tenant , 400 , 'Tenant not specified' ) ; // 例: テナント管理DBから接続情報を取る $ conn = $ this -> lookupTenantConnection ( $ tenant ) ; // 既存の 'mysql' を上書き Config :: set ( 'database.connections.mysql' , array_merge ( config ( 'database.connections.mysql' ) , [ 'host' => $ conn [ 'host' ] , 'database' => $ conn [ 'database' ] , 'username' => $ conn [ 'username' ] , 'password' => $ conn [ 'password' ] , ] )) ; // Swoole/Octane: コネクション再利用防止 DB :: purge ( 'mysql' ) ; // セッション名/キャッシュprefixも隔離(任意) config ([ 'session.cookie' => 'sess_' .$ tenant ]) ; config ([ 'cache.prefix' => 'cache_' .$ tenant ]) ; try { return $ next ( $ request ) ; } finally { // 念のため切断(重要) DB :: disconnect ( 'mysql' ) ; } } private function resolveTenant ( $ request ) : ? string { // 例: サブドメイン from Host $ host = $ request -> getHost () ; // tenant1.example.com $ parts = explode ( '.' , $ host ) ; return $ parts [ 0 ] ?? null ; } private function lookupTenantConnection ( string $ tenant ) : array { // 実装例: tenant_master から取得 or キャッシュ // ここでは直接返却していますが、管理用DBに接続して返却、設定ファイルを利用した返却も可。 return [ 'host' => 'aurora.cluster-xxxx.ap-northeast-1.rds.amazonaws.com' , 'database' => "sample_ { $ tenant } " , 'username' => 'app_user' , 'password' => '***' , ] ; } } メリット 同じタスク内で複数のテナントを処理できるため、サーバーコストも抑えやすい。 既存のリポジトリ/サービス層をほぼ変更せずにテナントごとにDB切り替えができるので一番低コストで実現可能。 サーバーアクセスの見込みが予測しやすく、突発的なアクセス増が起きにくい場合もメリットが大きくなる。 デメリット(重要) 各リクエストで必ず「purge/disconnect」が必要(Octane/Swooleで接続がワーカーに残るため) コンシューマ側アクセスが多いと接続の張り直しコストが効いてくる(パフォーマンス劣化のリスク) サーバアクセス増の考慮が他の方法よりもより慎重に対応する必要がある。 ② タスク(実行環境)をテナント分離(設定だけで完結) Ecspressoのタスク定義を分離させる対応を行うだけで、Laravel視点だとコード改修ゼロで対応可能。 ecspresso.yml region: ap-northeast-1 cluster: cluster_name service: tenant-xxxxxx-server service_definition: ecs-service-def.jsonnet task_definition: ecs-task-def.jsonnet timeout: "30m0s" plugins: - name: tfstate config: url: s3://tfstate-xxxxx.com/tenant.tfstate escpressoの設定ファイルに以下の定義をテナントごとにすることで、 DBの接続などをそれぞれに分け、タスクをテナント分離します。 Laravel側は「ecspressoの設定ファイル違い」だけ environments.libsonnet { "name": "APP_NAME", "value": ”tenantA" }, { "name": "DB_DATABASE", "value": "tenantA_db" }, { "name": "DB_USERNAME", "value": "tenantA" }, テナント毎にDBの接続先を変え、必要に応じてIDやPASSなども各テナントごとに変更する。 DBのパスワードなどはSeacretManagerなどを利用して設定する。 メリット コード改修なしで分離することができる。 性能/安全性/運用が単独サービスと同等で明確(Octaneの接続再利用も気にしなくて良い) アクセス増などはこの方式でもDB自体のリスクはあるが、他の方式よりも考慮点を少なくできる。 デメリット タスク分のサーバーコスト懸念がある。(ただし実務では“思ったほど増えない”ケースが多い) テナントが大量にあり、ひとつひとつが非常に少ない負荷で行える場合はこのデメリットは大きくなる。 設定がテナント分増えるので管理が煩雑になる可能性あり。 採用した結論について 上記内容を踏まえ、最終的に採用したのは方式②でした。 コードの修正が不要で今まで通りの処理をそのまま使用できる。 切り替え処理によるパフォーマンス劣化の検証、対策が不要でマルチテナント化できる。 ecspressoを採用しており、タスク定義の分離をしやすく、設定もテナントごとに管理しやすい。 設定は少し工夫することで、テナントが増えた際の設定内容も最小限にできる。 1番の懸念だったサーバーコスト増も方式①との差分もそこまで気になるレベルでなかったこと。 最初は方式①のつもりでしたが、サーバーコスト増の懸念が詳細にみてみると実はそこまで気にならないレベルだった点、 ①の対応は改修コストもそうですが、検証コストもより上乗せされる点や、特に初期段階では②のメリット部分が一番適している状態だったのもありました。 まとめ これまでの内容を表にまとめてみました。 パターン 内容 メリット デメリット 向いている条件 ① Middlewareで接続先判定+ mysql を書き換え リクエストごとに database.connections.mysql を書き換え、 DB::purge() / disconnect() する コード変更が最小 。既存リポジトリへの影響が小さい Octane/Swooleでは 都度切断が必須 。コンシューマ側アクセス増で 性能懸念 大量の既存コードをほぼ触れないで切り替えたい、少数テナント/低負荷、Octane/Swooleを採用しない方法もあり ② タスク(実行環境)をテナント分離 Ecspresso/ECSで テナントごとにタスクを分け 、 .env や Secret で接続先を分離 アプリ改修ほぼ不要 。 性能/安全性が明確 コスト懸念 (とはいえ実務では想定ほど増えないことも多い) テナント数が限定的、明確な分離・運用のわかりやすさを優先 今回は、コード改修ほぼゼロ・挙動が明確な ②(テナントごとに実行環境を分離)を採用していますが、 1テナント毎の規模は小さいが、大量のテナントを捌く要件なら ① も有力であると思いました。 但し、①は確実な切替制御、データ漏洩の対策、検証をしっかり行なった上という前提があるため、検証コストが多くかかってしまう可能性も大きいかなとも思いました。 おわりに いかがでしたでしょうか。 最初はマルチテナント化するには処理内で分岐させDBの接続を切り替える方式で良いかと思っていたのですが、 それぞれ検証してみると、様々なメリットデメリットがあり使い分けが非常に重要であることがわかりました。 インフラ費用面の懸念も結局ケースによるため、タスク分離がすぐに高コストにも一概には繋がらず検討が必要なことがわかりました。 今回はLaravelでお話はしていますが、考え方は他の環境でも活用できるのではないかと思っています。 本記事が、これからマルチテナント化を検討する上で少しでも参考になれば幸いです。 最後までお読みいただきありがとうございました。
アバター
はじめに Android 開発エンジニアを担当している岡田です。 今回はユーザーエンゲージメントを高める強力なツールである、 Engage SDK について導入のメリットについて解説できればと思います。 Engage SDK とは Engage SDK は、Google が 2024 年 7 月に「 Introducing Collections, a new on-device surface for your content 」というタイトルで発表しました。 この SDK を導入することで、アプリのコンテンツを Android システムの複数の場所に表示し、ユーザーエンゲージメントを向上させることができます。 Engage SDK の基本概念 Engage SDK では、コンテンツをカテゴリー別に分類し、それぞれに対応する Cluster、 Entity を定義しています。 開発者は、これらに沿った情報を Google に送信することで、適切な場所にコンテンツが表示されるようになります。 Engage SDK では、以下の 9 つのカテゴリーが用意されています。 Watch: 動画 Listen: 音楽 Read: 記事 Shopping: 買い物 Food: 食事 Social: ソーシャル Travel: 旅行 Health & Fitness: 健康 & フィットネス Other Verticals: その他 例として、Watch カテゴリーを見てみましょう。 Watch カテゴリーには、Recommendation、Continuation、Featured の 3 つの Cluster タイプが含まれています。 ここでいう Cluster とは、デベロッパーが提供するコンテンツをまとめて表示するための UI(ユーザーインターフェース)の単位です。 Engage SDK 全体で、Recommendation、Continuation、Featured の 3 つの Cluster タイプが定義されています。 Watch カテゴリーにおける、各 Cluster の役割は以下の通りです。 Cluster 説明 Recommendation 個々の開発者パートナーからのコンテンツを視聴するためのパーソナライズされた提案を表示します。 Continuation 複数の開発者パートナーからの未完成の動画や、関連する新しくリリースされたエピソードを1つのUIグループに表示します。 Featured 複数の開発者パートナーからのエンティティのセレクションを1つのUIグループに表示します。 Cluster についての詳細は、 Engage SDK Cluster publishing guidelines に詳しく記載されていますので、併せてご確認ください。 また、Watch カテゴリーでは、Cluster 内の単一のアイテムを表すオブジェクトである、Entity が 6 種類定義されています。 Entity 説明 MovieEntity (映画) 映画コンテンツを表すエンティティです。タイトル、ポスター画像、再生時間などの情報を含みます。 TvShowEntity (テレビ番組) テレビ番組シリーズ全体を表すエンティティです。番組名やジャンル、シーズン情報へのリンクなどが含まれます。 TvSeasonEntity (テレビシーズン) 特定のテレビ番組のシーズンを表すエンティティです。シーズン番号やエピソードリストへのリンクを持ちます。 TvEpisodeEntity (テレビエピソード) テレビ番組の個々のエピソードを表すエンティティです。エピソード番号、タイトル、あらすじなどが含まれます。 LiveStreamingVideoEntity (ライブ配信動画) ライブストリーミング中の動画コンテンツを表すエンティティです。ライブの開始・終了時刻や現在の視聴者数などの情報を含みます。 VideoClipEntity (ビデオクリップ) 短い動画クリップを表すエンティティです。ミュージックビデオやニュースクリップ、ハイライトシーンなどに利用されます。 動画視聴アプリであれば、アプリ内のコンテンツをこれらの Entity に変換し、Cluster へ登録・公開するといった実装の流れになります。 具体的な実装方法 以下は Google が公開しているサンプルコードの中から抜粋した、 FeaturedCluster に MovieEntity を追加し、公開している constructFeaturedClusterRequest メソッドです。 suspend fun constructFeaturedClusterRequest(): PublishFeaturedClusterRequest { // おすすめとして表示する映画のリストを取得 val featuredList = movieDao.loadMovieIsCurrentlyWatching(false) // FeaturedClusterを構築するためのBuilderを初期化します。 val featuredCluster = FeaturedCluster.Builder() // 取得した映画のリスト featuredList を featuredCluster へ追加する for (item in featuredList) { featuredCluster.addEntity(ItemToEntityConverter.convertMovie(item)) } // PublishFeaturedClusterRequest(Featuredクラスタ公開リクエスト)を構築 return PublishFeaturedClusterRequest.Builder() .setFeaturedCluster(featuredCluster.build()) .build() } github.com 他カテゴリーでも、実装の流れは同じになります。 詳細については、 Engage SDK Codelab  |  Android Developers が用意されていますので、こちらをご参照ください。 Engage SDK を導入するメリット 表示される場所 Engage SDK を導入することで、アプリのコンテンツは以下の場所に表示されるようになります。 表示 アクセス方法・対象 機能 特徴 備考 スマートフォンのコレクション Play Store のアイコンを長押し > コレクション パーソナライズされたおすすめコンテンツを表示 ユーザーの興味に基づいた動的なコンテンツ推薦 米国を中心に展開中 タブレットのエンターテイメントスペース Android タブレットのホーム画面左端(一部メーカーの端末では非搭載、または異なる機能の場合があります)。 エンターテイメントコンテンツの推奨表示 大画面を活用したリッチなコンテンツ表示 Google Play ストア アプリの詳細ページなど 関連コンテンツの表示 アプリ発見時の追加コンテンツ提供 導入メリット Engage SDK をアプリに導入することは、開発者にとって多くの戦略的利点をもたらします。 そのメリットは、ユーザーエンゲージメントの向上から開発負担の軽減まで多岐にわたります。 1. ユーザーエンゲージメントの飛躍的向上 ユーザーがアプリを直接開かなくても、スマートフォンのホーム画面などから直接コンテンツにアクセスできるようになります。 Google の機械学習を活用したパーソナライズ機能により、個々のユーザーの興味関心に基づいたコンテンツが推薦されるため、クリック率の向上が期待でき、アクティブ率の維持・向上に大きく貢献する可能性があります。 2. Googleプラットフォーム上でのアプリの可視性向上 アプリ内のコンテンツが Google Play ストアやエンターテイメントスペースなど、複数の Google のサービス(サーフェス)に表示されるため、ユーザーへの露出が増加します。 これにより、既存ユーザーの満足度を高めるだけでなく、これまでアプリを知らなかった潜在的な新規ユーザーを獲得する絶好の機会が生まれます。 3. 開発負担の軽減とスムーズな統合 技術的な観点からも、導入のハードルは低く設計されています。 SDK は非常に軽量で、平均的なアプリの APK サイズ増加は圧縮状態で 50KB 未満に抑えられると明記されています。 また、既存のアプリ構造を大きく変更することなく統合できるため、開発期間は約 1 週間程度と短期間で完了します。 これにより、少ないリソースで大きな効果を狙うことが可能です。 4. データに基づいた継続的な改善 データ駆動型のアプローチにより、提供するコンテンツを継続的に最適化できます。 Google の高度な機械学習がユーザーの行動をリアルタイムで分析し、最も効果的なコンテンツ推薦を自動で行います。 開発者はパフォーマンスに関する分析結果や改善提案を活用し、常にユーザー体験を向上が期待できます。 今後の展望 Engage SDK は現在、米国を中心に、日本を含むいくつかの国で展開されています。現在の認知度はまだ高くない状態ですが、今後露出が増えると考えられます。 アプリ開発者は、この新しいプラットフォームを早期に活用することで、競合他社に先駆けてユーザーとの接点を増やし、アプリの成長と成功につなげられるでしょう。 まとめ Engage SDK は、Android アプリ開発者にとって画期的なツールです。 従来アプリ内に閉じていたコンテンツを Google Play ストアやスマートフォンのコレクションなど、Android システムの複数の場所に展開することで、ユーザーエンゲージメントの大幅な向上を実現します。 参考資料 developer.android.com developer.android.com developer.android.com android-developers.googleblog.com
アバター
はじめに 開発1部でデリッシュキッチンのプレミアム機能の開発を担当している岩﨑です。 私は入社して初めてWebフレームワークの labstack/echo に触れました。 使っていく中で「便利だけどこれどうやって動いているんだろう?」と思うことが増えてきました。 そこを意識しなくていいのがフレームワークの良いところなんだとは思いますが、気になるので内部実装を覗いてみようと思います。 要約 ここから先は順次実装を追っていくので最初に結論を記載しておきます。 Echoのハンドラ関数が echo.Context を引数に取るのは、フレームワークの HandlerFunc 型でそのように定義されているため。 echo.Context は、HTTPリクエストを受け取った際にDIされたEchoの ServeHTTP メソッド内でオブジェクトプールから取得・初期化される。 そして、ルーティングによってリクエストに対応するハンドラが特定され、この echo.Context が引数として渡されて実行される。 echo.Contextはどこからきているのか 見出しに記載の通りですが、今回はecho.Contextってどこから呼ばれてるんだろう、という疑問を解消したいと思います。 これがどういう問いなのかは、コードを読んでもらった方が早いと思います。 以下はEchoを使用した最小のWebサーバーのコードです。 package main import ( "net/http" "github.com/labstack/echo/v4" ) // Handler func hello(c echo.Context) error { return c.String(http.StatusOK, "Hello, World!" ) } func main() { // Echo instance e := echo.New() // Routes e.GET( "/" , hello) // Start server e.Start( ":1323" ) } これは、ルートパス / にGETリクエストを処理するハンドラを追加し、そのハンドラは"Hello, World!"レスポンスを返すようなシンプルなWebサーバーです。 echo.New() でechoのインスタンスを作成しています。 ここで、ハンドラ関数の引数としてecho.Contextを受け取っていますが、これはどこからきているのでしょうか? ハンドラ関数の引数にecho.Contextを設定しなければならない理由 まずはハンドラ関数を登録している部分の内部実装を詳しく見ていきます。 e.GET() メソッドの実装を見ると、第2引数として HandlerFunc 型を期待していることがわかります。(※1) // github.com/labstack/echo/v4@v4.13.4/echo.go func (e *Echo) GET(path string , h HandlerFunc, m ...MiddlewareFunc) *Route { return e.Add(http.MethodGet, pth, h, m...) } HandlerFunc の型定義により、登録するすべてのハンドラ関数は以下のシグネチャに従う必要があります。 引数:echo.Contextを受け取る 戻り値:errorを返す // github.com/labstack/echo/v4@v4.13.4/echo.go // HandlerFunc defines a function to serve HTTP requests. type HandlerFunc func (c Context) error よってこの型定義に従わないハンドラを登録しようとすると、下記のようにエラーが出ます。 以上により、echoフレームワークを使用したハンドラ関数には必ずecho.Contextを引数として定義しなければならないことがわかりました。 ではどこからecho.Contextがきているのか ここからが本題です。 GETメソッドが呼ばれた時に、どのようにハンドラにecho.Contextが渡されるのかについて見ていきます。 クライアントからHTTPリクエストがくると、Goの標準ライブラリであるhttp.ServerがServeHTTPメソッドを呼び出します。 net/http ここでGoの標準ライブラリである net/http パッケージについて簡単に触れておきます。 net/http パッケージのHandlerインターフェースは、 ServeHTTP メソッドを1つだけ持ちます。 type Handler interface { ServeHTTP(ResponseWriter, *Request) } Web サーバがリクエストを受け取ると ServeHTTP が各リクエストごとに実行され、レスポンスの出力先である http.ResponseWriter にレスポンス内容が書き込まれます。 標準パッケージ内では基底コンテキストを定義し、接続ごとのコンテキストを組み立てていることがわかります。 func (srv *Server) Serve(l net.Listener) error { // 省略 baseCtx := context.Background() if srv.BaseContext != nil { baseCtx = srv.BaseContext(origListener) if baseCtx == nil { panic ( "BaseContext returned a nil context" ) } } // 省略 ctx := context.WithValue(baseCtx, ServerContextKey, srv) for { // 省略 connCtx := ctx if cc := srv.ConnContext; cc != nil { connCtx = cc(connCtx, rw) if connCtx == nil { panic ( "ConnContext returned nil" ) } } // 省略 go c.serve(connCtx) } EchoのServeHTTP echo.New() で作成されるEchoインスタンスは、先ほど説明した http.Handler インターフェースを実装しています。 つまりEchoも ServeHTTP メソッドを持っています。 Echoのインスタンス作成時に http.Server のHandlerフィールドに依存性注入(DI)しているため、リクエストを受け取るとEchoインスタンスの ServeHTTP が呼ばれるのです。 // github.com/labstack/echo/v4@v4.13.4/echo.go func New() (e *Echo) { e = &Echo{ Server: new (http.Server), // その他の初期化 } // 省略 e.Server.Handler = e // 省略 return } ではEchoのServeHTTPメソッドをもう少し詳しくみていきます。 // github.com/labstack/echo/v4@v4.13.4/echo.go // ServeHTTP implements `http.Handler` interface, which serves HTTP requests. func (e *Echo) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Acquire context c := e.pool.Get().(*context) c.Reset(r, w) var h HandlerFunc if e.premiddleware == nil { e.findRouter(r.Host).Find(r.Method, GetPath(r), c) h = c.Handler() h = applyMiddleware(h, e.middleware...) } else { h = func (c Context) error { e.findRouter(r.Host).Find(r.Method, GetPath(r), c) h := c.Handler() h = applyMiddleware(h, e.middleware...) return h(c) } h = applyMiddleware(h, e.premiddleware...) } // Execute chain if err := h(c); err != nil { e.HTTPErrorHandler(err, c) } // Release context e.pool.Put(c) } まず e.pool.Get().(*context) でプール(※2)からcontextを取得し、Resetメソッドで初期化します。 ここで、Echoのcontext構造体は以下のようなリクエスト、レスポンスに関する情報を持ちます。 // github.com/labstack/echo/v4@v4.13.4/context.go type context struct { logger Logger request *http.Request response *Response query url.Values echo *Echo store Map lock sync.RWMutex // following fields are set by Router handler HandlerFunc // path is route path that Router matched. It is empty string where there is no route match. // Route registered with RouteNotFound is considered as a match and path therefore is not empty. path string // Usually echo.Echo is sizing pvalues but there could be user created middlewares that decide to // overwrite parameter by calling SetParamNames + SetParamValues. // When echo.Echo allocated that slice it length/capacity is tied to echo.Echo.maxParam value. // // It is important that pvalues size is always equal or bigger to pnames length. pvalues [] string // pnames length is tied to param count for the matched route pnames [] string } 次に e.findRouter(r.Host).Find(r.Method, GetPath(r), c) によりHTTPリクエストから取得したHTTPメソッドとパスから探索を行います。(※3) 詳細な探索アルゴリズムは割愛しますが、ルーティングは「静的パス > パラメータ付きパス > ワイルドカードパス」の順にツリーのノードを探索します。 そしてルートが見つかった場合、以下の情報がcontextに設定されます。 ハンドラ関数( ctx.handler ) ルートパス( ctx.path ) パラメータ名の配列( ctx.pnames ) // github.com/labstack/echo/v4@v4.13.4/router.go func (r *Router) Find(method, path string , c Context) { // 省略 var rPath string var rPNames [] string if matchedRouteMethod != nil { rPath = matchedRouteMethod.ppath rPNames = matchedRouteMethod.pnames ctx.handler = matchedRouteMethod.handler } else { // 省略 } ctx.path = rPath ctx.pnames = rPNames } こうして得られたハンドラ関数にecho.Contextを引数として渡すことで処理が実行されます。(※4) よって先ほど定義したルートパスのhello関数はここで実行されているのです。 // github.com/labstack/echo/v4@v4.13.4/echo.go // ServeHTTP implements `http.Handler` interface, which serves HTTP requests. func (e *Echo) ServeHTTP(w http.ResponseWriter, r *http.Request) { // 省略 // Execute chain if err := h(c); err != nil { e.HTTPErrorHandler(err, c) } // Release context e.pool.Put(c) } あとは e.HTTPErrorHandler(err, c) によるエラーハンドリングを経て、contextをプール(※1)へPUTすれば処理は完了です。 まとめ まとめは以下の通りです。 Echoのハンドラ関数が echo.Context を引数に取るのは、フレームワークの HandlerFunc 型でそのように定義されているため。 echo.Context は、HTTPリクエストを受け取った際にDIされたEchoの ServeHTTP メソッド内でオブジェクトプールから取得・初期化される。 そして、ルーティングによってリクエストに対応するハンドラが特定され、この echo.Context が引数として渡されて実行される。 ほんの一部ですが、Echoの中を覗くことでHTTPリクエストがどの流れで処理されているのか大枠把握することができました。 調べているうちにmiddlewareで何をしているのかが気になってきたので、次はそこを調べてみようと思います。 最後まで読んでいただきありがとうございました! 脚注 ※1) 今回はGETメソッドを使っていますが、その他のHTTPメソッドでも同じ登録処理を行なっています。 ※2) sync package - sync - Go Packages ※3)ミドルウェアの処理は今回のスコープ外なので省略しています。 ※4)実際には、ハンドラ関数はミドルウェアでラップされておりミドルウェアのチェーン処理の実行後にハンドラ関数が実行されますが、今回の記事のスコープ外なので省略しています。
アバター
はじめに この度、株式会社エブリーは、2025 年 9 月 27 日(土)、28 日(日)に開催される「Go Conference 2025」に、Platinum "Go"ld スポンサーとして昨年に続き協賛することになりました! Go Conferenceとは? gocon.jp プログラミング言語 ”Go”ユーザーのためのカンファレンスです。今年はなんと2日間開催で、セッションはもちろん、ワークショップや懇親会など多数のコンテンツが用意されています! 今年の開催概要は以下のとおりです。 開催日時 Day 1: 2025年9月27日(土) Day 2: 2025年9月28日(日) 開催場所 東京都渋谷区宇田川町40番1号 Abema Towers 10F / 11F コンテンツ ・スポンサーセッション ・公募セッション(Long / Short) ・LT ・ワークショップ ・懇親会 セッションやワークショップは、言語使用から実務に近い話まで様々な内容が見られます。2025年8月に Go 1.25 がリリースされたこともあり、それに関するセッションも注目したいところです!筆者個人としては、ジェネリクス関連のセッションがいくつかあるので気になっています! イベント当日について Day 2 の 28日(日)18:00~18:20に、弊社の本丸からスポンサーセッションをさせていただきます!「 10年もののAPIサーバーにおけるCI/CDの改善の奮闘 」というタイトルで、Goとechoで構成されたAPIサーバーのCI/CD高速化についてお話する予定です。ぜひお越しください! gocon.jp エブリーのブースでは、Xフォローでノベルティやキッチングッズなどが当たるくじ引き、アンケートボード、フォトブースを設置します。 Go Conference 2025 で皆さんと良い学び・思い出を作れることを楽しみにしています! ノベルティ 非公式アフターイベントのご案内 Go BASH Vol.2 LayerX、ANDPAD、OPTiM、Resilire、エブリーの5社合同で、非公式アフターイベント Go BASH Vol.2 を開催します! 弊社からは本丸、hondが登壇します!セッションや感想戦などのコンテンツを用意しておりますので、みんなでGoで盛り上がりましょう! 開催日時 2025年10月6日(月) 開催場所 東京都港区海岸1丁目2番20号 汐留ビルディング18F OPTiM TOKYO(東京本社) 開催形態 オフライン / オンライン コンテンツ ・各社のGoに関するセッション ・Go Conference 2025 感想戦 ・交流会 お申し込みは以下で行っております。みなさんのご参加をお待ちしています! layerx.connpass.com 最後までお読みいただき、ありがとうございました!
アバター
目次 はじめに 学習の背景と環境 ディレクトリ構造 Cursor rules 学習開始時のプロンプト 学習の流れ スライスの内部構造について スライスの容量拡張アルゴリズムについて 計算量とパフォーマンスの要点 スライスが起こすメモリリーク 文字列におけるメモリリーク 多次元スライスのループ処理 ネストした構造体のループ処理 メモリプールの活用 所感 おわりに 参考 はじめに こんにちは。 開発本部開発1部トモニテ開発部所属の庄司( @ktanonymous )です。 最近、AI を利用して Go のスライスやメモリ、パフォーマンスに関する内容を学習してみる機会がありました。 今回はその際の体験についてまとめてみたいと思います。 なお、AI の出力に関しては出力内容をそのまま使用しているため一部誤っている箇所もありますが、 飽くまでも対話のログとして基本的には改変せずに記載しています。 記事本文と AI の出力で内容が異なる場合は記事本文の内容を優先してください。 また、cursor の対話を通して学ばなかったことについては触れないので、 「xxには◯◯も使えるよ」と思うこともあるかもしれませんがご了承ください。 学習の背景と環境 目的 : 完全に AI 主導による学習体験を検証する 利用モデル : claude-4-sonnet-thinking AI エディタ : Cursor ディレクトリ構造 /training ├── .cursor │ └── rules │ └── project_description.mdc └── go_slice ├── compose.yml ├── docker │ └── app ├── examples # サンプルコード ├── README.md └── src └── go.mod Cursor rules ※ 学習を進めながら更新した部分もあります --- description: globs: alwaysApply: true --- # プロジェクト構成について - このプロジェクトはユーザーが学習のために利用します。 ## ディレクトリ構成について - 学習する領域ごとにディレクトリを作成します。 - 学習環境はコンテナ環境で実行します。 - ディレクトリ内のトップレベルに ` compose.yml ` を、 ` docker ` ディレクトリに役割ごとのディレクトリを作成して ` Dockerfile ` を作成してください。 - 例 ```bash - react/ - compose.yml - docker - app - Dockerfile - db - Dockerfile - src ``` - 参考情報に関しては、web検索などで最新の公式ドキュメントを参照してください。 ## 学習の進め方について - 学習内容の本質を身につけるためにサンプルアプリケーションを作成します - 学習は環境構築から始めます - 学習内容を定着させるために、実行するべきコマンドや実装するべきコードをクイズ形式にしてください - クイズの回答や確認事項に対する返答を待たずに次の話を進めないでください。 - 学習の経過を確認できるように、各ステップで作成したサンプルコードは削除したり上書きしたりしないでください。 - 学習テーマは会話のたびに必ず思い出して忘れないようにしてください。 学習開始時のプロンプト ## 学習内容 goのスライスは配列の根本的な仕組みについて、細かい動作仕様や時間/空間計算量などの観点から深掘りたいです ## 制限 上記の学習内容は会話の最初に必ず思い出して忘れないようにしてください。 学習の流れ 学習は以下のようなステップで進みました。 基本的には、各ステップで「狙いの提示 -> サンプルコード実装 -> クイズ -> 解答・解説」を繰り返しています。 また、エラーなどで学習自体が詰まることはなく、 途中で気になった内容に関しては追加で確認することもできました。 環境構築 スライス内部構造 計算量とパフォーマンス メモリリーク 文字列の参照問題 深いネスト最適化 メモリプール活用 以降で実際に学んだ内容について整理しつつ、 学習の経過を共有し、最後に所感についてまとめたいと思います。 なお、実際の作業は cursor が最初に作ってくれた以下の開発環境で行いました。 開発環境 compose.yml services : go-slice-study : build : context : . dockerfile : ./docker/app/Dockerfile container_name : go-slice-study volumes : - ./src:/app/src - ./examples:/app/examples working_dir : /app stdin_open : true tty : true command : [ "sh" , "-c" , "tail -f /dev/null" ] docker/app/Dockerfile # 最新のGoの公式イメージを使用 FROM golang:1.22-alpine # 必要なツールをインストール RUN apk add --no-cache \ git \ bash \ curl \ build-base # 作業ディレクトリを設定 WORKDIR /app # Go modulesを有効化 ENV GO111MODULE=on # 学習用の基本環境には不要なツールをスキップ # RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest # アプリケーション用のディレクトリを作成 RUN mkdir -p /app/src /app/examples # デフォルトのシェルをbashに設定 RUN echo ' export PS1="\[\033[01;32m\]go-slice-study\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]$ " ' >> ~/.bashrc CMD [ " bash " ] go.mod module go-slice-study go 1.22 require ( github.com/stretchr/testify v1.8.4 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) スライスの内部構造について サンプル1(実装+実行結果) 実装 package main import ( "fmt" "unsafe" ) // スライスの内部構造を表現する構造体 // runtime/slice.goで定義されているものと同じ type SliceHeader struct { Data uintptr // 配列への先頭要素のポインタ Len int // スライスの長さ Cap int // スライスの容量 } func main() { fmt.Println( "=== Goスライスの内部構造学習 === \n " ) // 1. スライスリテラルの作成 fmt.Println( "1. スライスリテラルの作成" ) s1 := [] int { 1 , 2 , 3 , 4 , 5 } printSliceInfo( "s1" , s1) // 2. makeを使った作成 fmt.Println( " \n 2. makeを使った作成" ) s2 := make ([] int , 3 , 5 ) // len=3, cap=5 printSliceInfo( "s2" , s2) // 3. スライシング操作 fmt.Println( " \n 3. スライシング操作" ) s3 := s1[ 1 : 4 ] // インデックス1から3まで printSliceInfo( "s3 (s1[1:4])" , s3) // s1とs3が同じ配列を参照していることを確認 fmt.Printf( "s1とs3が同じ配列を参照: %t \n " , unsafe.Pointer (&s1[ 1 ]) == unsafe.Pointer (&s3[ 0 ])) // 4. append操作による容量拡張 fmt.Println( " \n 4. append操作による容量拡張" ) s4 := [] int { 1 , 2 , 3 } printSliceInfo( "s4 (before append)" , s4) s4 = append (s4, 4 , 5 , 6 , 7 ) printSliceInfo( "s4 (after append)" , s4) // 5. 容量が足りない場合の再割り当て fmt.Println( " \n 5. 容量が足りない場合の再割り当て" ) s5 := make ([] int , 3 , 3 ) // 満杯の状態 s5[ 0 ], s5[ 1 ], s5[ 2 ] = 10 , 20 , 30 printSliceInfo( "s5 (満杯状態)" , s5) oldPtr := unsafe.Pointer (&s5[ 0 ]) s5 = append (s5, 40 ) // 再割り当てが発生 newPtr := unsafe.Pointer (&s5[ 0 ]) printSliceInfo( "s5 (append後)" , s5) fmt.Printf( "配列が再割り当てされた: %t \n " , oldPtr != newPtr) } // スライスの詳細情報を出力する関数 func printSliceInfo(name string , s [] int ) { if len (s) == 0 { fmt.Printf( "%s: [], len=%d, cap=%d, ptr=nil \n " , name, len (s), cap (s)) return } header := (*SliceHeader)( unsafe.Pointer (&s)) fmt.Printf( "%s: %v, len=%d, cap=%d, ptr=0x%x \n " , name, s, len (s), cap (s), header.Data) } 実行結果 === Goスライスの内部構造学習 === 1. スライスリテラルの作成 s1: [1 2 3 4 5], len=5, cap=5, ptr=0x400007e030 2. makeを使った作成 s2: [0 0 0], len=3, cap=5, ptr=0x400007e060 3. スライシング操作 s3 (s1[1:4]): [2 3 4], len=3, cap=4, ptr=0x400007e038 s1とs3が同じ配列を参照: true 4. append操作による容量拡張 s4 (before append): [1 2 3], len=3, cap=3, ptr=0x4000014078 s4 (after append): [1 2 3 4 5 6 7], len=7, cap=8, ptr=0x400010c000 5. 容量が足りない場合の再割り当て s5 (満杯状態): [10 20 30], len=3, cap=3, ptr=0x4000014090 s5 (append後): [10 20 30 40], len=4, cap=6, ptr=0x400007e090 配列が再割り当てされた: true クイズ1 ## **🎯 クイズ第1問** サンプル1の実行結果を見て、以下の質問に答えてください: **問題**: ` s3 := s1[1:4] ` の操作で、 ` s3 ` のポインタ値が ` 0x40000b2038 ` になり、 ` s1 ` のポインタ値 ` 0x40000b2030 ` とは8バイト(0x8)の差があります。 **なぜ8バイトの差があるのでしょうか?** この差の意味と、それがスライスの仕組みと配列の要素型にどう関係しているか説明してください。 **ヒント**: - ` s1 ` の型は ` []int ` です - ` s3 ` は ` s1[1:4] ` 、つまり ` s1 ` のインデックス1から始まります - 64ビットシステムでの ` int ` のサイズを考えてみてください あなたの回答をお聞かせください。 まず「配列参照としてのスライス」を体感するため、 スライシング後のポインタ差分と型サイズの関係を検証しました。 Go のスライスは「配列を指す参照」の構造体であり、以下の 3 要素から構成されます 1 2 。 (部分)配列先頭を指すポインタ(ptr) 長さ(len) 容量(cap) type slice struct { array unsafe.Pointer len int cap int } 例えば、 s := []int64{1, 2} というスライスがある場合、 s[0] と s[1] のポインタ値の差分は 64 bit = 8 Byte となります。 サンプル2(データ型とメモリの関係性を追加検証)(実装+実行結果) 実装 package main import ( "fmt" "unsafe" ) func main() { fmt.Println( "=== 64ビットシステムでのintのサイズとメモリレイアウト === \n " ) // 1. データ型のサイズを確認 fmt.Println( "1. データ型のサイズ" ) fmt.Printf( "int のサイズ: %d バイト \n " , unsafe.Sizeof( int ( 0 ))) fmt.Printf( "int32 のサイズ: %d バイト \n " , unsafe.Sizeof( int32 ( 0 ))) fmt.Printf( "int64 のサイズ: %d バイト \n " , unsafe.Sizeof( int64 ( 0 ))) fmt.Printf( "byte のサイズ: %d バイト \n " , unsafe.Sizeof( byte ( 0 ))) // 2. 配列内での要素の配置を確認 fmt.Println( " \n 2. 配列内での要素の配置" ) arr := [ 5 ] int { 10 , 20 , 30 , 40 , 50 } fmt.Println( "各要素のメモリアドレス:" ) for i := 0 ; i < len (arr); i++ { fmt.Printf( "arr[%d] = %d, アドレス: 0x%x \n " , i, arr[i], uintptr ( unsafe.Pointer (&arr[i]))) } // 3. アドレスの差を計算 fmt.Println( " \n 3. 隣接する要素間のアドレス差:" ) for i := 0 ; i < len (arr)- 1 ; i++ { addr1 := uintptr ( unsafe.Pointer (&arr[i])) addr2 := uintptr ( unsafe.Pointer (&arr[i+ 1 ])) diff := addr2 - addr1 fmt.Printf( "arr[%d]とarr[%d]のアドレス差: %d バイト \n " , i, i+ 1 , diff) } // 4. スライスでの実際の検証 fmt.Println( " \n 4. スライスでの実際の検証" ) s1 := [] int { 1 , 2 , 3 , 4 , 5 } s3 := s1[ 1 : 4 ] fmt.Printf( "s1のポインタ: 0x%x (要素[0]のアドレス) \n " , uintptr ( unsafe.Pointer (&s1[ 0 ]))) fmt.Printf( "s1[1]のアドレス: 0x%x \n " , uintptr ( unsafe.Pointer (&s1[ 1 ]))) fmt.Printf( "s3のポインタ: 0x%x (要素[0]のアドレス) \n " , uintptr ( unsafe.Pointer (&s3[ 0 ]))) addr_s1_0 := uintptr ( unsafe.Pointer (&s1[ 0 ])) addr_s1_1 := uintptr ( unsafe.Pointer (&s1[ 1 ])) addr_s3_0 := uintptr ( unsafe.Pointer (&s3[ 0 ])) fmt.Printf( " \n s1[0]とs1[1]のアドレス差: %d バイト \n " , addr_s1_1-addr_s1_0) fmt.Printf( "s1[1]とs3[0]のアドレス: 同じか? %t \n " , addr_s1_1 == addr_s3_0) // 5. 異なるデータ型での比較 fmt.Println( " \n 5. 異なるデータ型での比較" ) // byte配列の場合 byteArr := [ 5 ] byte { 1 , 2 , 3 , 4 , 5 } fmt.Println( "byte配列での隣接要素のアドレス差:" ) for i := 0 ; i < len (byteArr)- 1 ; i++ { addr1 := uintptr ( unsafe.Pointer (&byteArr[i])) addr2 := uintptr ( unsafe.Pointer (&byteArr[i+ 1 ])) diff := addr2 - addr1 fmt.Printf( "byteArr[%d]とbyteArr[%d]のアドレス差: %d バイト \n " , i, i+ 1 , diff) } // int32配列の場合 int32Arr := [ 5 ] int32 { 10 , 20 , 30 , 40 , 50 } fmt.Println( "int32配列での隣接要素のアドレス差:" ) for i := 0 ; i < len (int32Arr)- 1 ; i++ { addr1 := uintptr ( unsafe.Pointer (&int32Arr[i])) addr2 := uintptr ( unsafe.Pointer (&int32Arr[i+ 1 ])) diff := addr2 - addr1 fmt.Printf( "int32Arr[%d]とint32Arr[%d]のアドレス差: %d バイト \n " , i, i+ 1 , diff) } } 実行結果 === 64ビットシステムでのintのサイズとメモリレイアウト === 1. データ型のサイズ int のサイズ: 8 バイト int32 のサイズ: 4 バイト int64 のサイズ: 8 バイト byte のサイズ: 1 バイト 2. 配列内での要素の配置 各要素のメモリアドレス: arr[0] = 10, アドレス: 0x4000074cf8 arr[1] = 20, アドレス: 0x4000074d00 arr[2] = 30, アドレス: 0x4000074d08 arr[3] = 40, アドレス: 0x4000074d10 arr[4] = 50, アドレス: 0x4000074d18 3. 隣接する要素間のアドレス差: arr[0]とarr[1]のアドレス差: 8 バイト arr[1]とarr[2]のアドレス差: 8 バイト arr[2]とarr[3]のアドレス差: 8 バイト arr[3]とarr[4]のアドレス差: 8 バイト 4. スライスでの実際の検証 s1のポインタ: 0x4000074d30 (要素[0]のアドレス) s1[1]のアドレス: 0x4000074d38 s3のポインタ: 0x4000074d38 (要素[0]のアドレス) s1[0]とs1[1]のアドレス差: 8 バイト s1[1]とs3[0]のアドレス: 同じか? true 5. 異なるデータ型での比較 byte配列での隣接要素のアドレス差: byteArr[0]とbyteArr[1]のアドレス差: 1 バイト byteArr[1]とbyteArr[2]のアドレス差: 1 バイト byteArr[2]とbyteArr[3]のアドレス差: 1 バイト byteArr[3]とbyteArr[4]のアドレス差: 1 バイト int32配列での隣接要素のアドレス差: int32Arr[0]とint32Arr[1]のアドレス差: 4 バイト int32Arr[1]とint32Arr[2]のアドレス差: 4 バイト int32Arr[2]とint32Arr[3]のアドレス差: 4 バイト int32Arr[3]とint32Arr[4]のアドレス差: 4 バイト スライスの容量拡張アルゴリズムについて サンプル3(実装+実行結果) 実装 package main import ( "fmt" ) func main() { fmt.Println( "=== Goスライスの容量拡張アルゴリズム検証 === \n " ) // 1. s4のケースを再現・分析 fmt.Println( "1. s4のケース分析: append(s4, 4, 5, 6, 7)" ) s4 := [] int { 1 , 2 , 3 } // len=3, cap=3 fmt.Printf( "s4 初期状態: len=%d, cap=%d \n " , len (s4), cap (s4)) fmt.Println( "- 現在の要素数: 3" ) fmt.Println( "- 追加する要素数: 4個" ) fmt.Println( "- 必要な合計要素数: 3 + 4 = 7" ) fmt.Println( "- 容量を2倍にすると: 3 × 2 = 6" ) fmt.Println( "- 必要な要素数(7) > 2倍の容量(6) なので、容量は7以上が必要" ) s4 = append (s4, 4 , 5 , 6 , 7 ) fmt.Printf( "s4 拡張後: len=%d, cap=%d \n " , len (s4), cap (s4)) fmt.Println( "→ 結果: 7が必要だが、実際は8になっている(メモリアライメント)" ) // 2. s5のケースを再現・分析 fmt.Println( " \n 2. s5のケース分析: append(s5, 40)" ) s5 := make ([] int , 3 , 3 ) // len=3, cap=3 s5[ 0 ], s5[ 1 ], s5[ 2 ] = 10 , 20 , 30 fmt.Printf( "s5 初期状態: len=%d, cap=%d \n " , len (s5), cap (s5)) fmt.Println( "- 現在の要素数: 3" ) fmt.Println( "- 追加する要素数: 1個" ) fmt.Println( "- 必要な合計要素数: 3 + 1 = 4" ) fmt.Println( "- 容量を2倍にすると: 3 × 2 = 6" ) fmt.Println( "- 必要な要素数(4) < 2倍の容量(6) なので、容量は6になる" ) s5 = append (s5, 40 ) fmt.Printf( "s5 拡張後: len=%d, cap=%d \n " , len (s5), cap (s5)) fmt.Println( "→ 結果: 予想通り6になっている" ) // 3. Goの容量拡張ルールの検証 fmt.Println( " \n 3. Goの容量拡張ルール:" ) fmt.Println( "ステップ1: 新しい容量を決定" ) fmt.Println( " - 現在の容量を2倍する" ) fmt.Println( " - もし必要な要素数が2倍容量より大きければ、必要な要素数を採用" ) fmt.Println( "ステップ2: メモリアライメント調整" ) fmt.Println( " - CPUとメモリの効率化のため、特定のサイズに調整される" ) // 4. さまざまなパターンでの検証 fmt.Println( " \n 4. さまざまなパターンでの検証" ) testCases := [] struct { name string initialCap int appendCount int }{ { "1つ追加" , 3 , 1 }, { "2つ追加" , 3 , 2 }, { "3つ追加" , 3 , 3 }, { "4つ追加" , 3 , 4 }, { "5つ追加" , 3 , 5 }, { "10つ追加" , 3 , 10 }, } for _, tc := range testCases { s := make ([] int , tc.initialCap, tc.initialCap) fmt.Printf( " \n %s (初期容量:%d): \n " , tc.name, tc.initialCap) // 追加する要素を準備 toAppend := make ([] int , tc.appendCount) for i := range toAppend { toAppend[i] = 100 + i } fmt.Printf( " 追加前: len=%d, cap=%d \n " , len (s), cap (s)) fmt.Printf( " 追加要素数: %d \n " , tc.appendCount) fmt.Printf( " 必要合計要素数: %d \n " , tc.initialCap+tc.appendCount) fmt.Printf( " 2倍容量: %d \n " , tc.initialCap* 2 ) s = append (s, toAppend...) fmt.Printf( " 結果: len=%d, cap=%d \n " , len (s), cap (s)) // 予想と実際の比較 expected := tc.initialCap * 2 needed := tc.initialCap + tc.appendCount if needed > expected { fmt.Printf( " → 必要数(%d) > 2倍容量(%d) なので %d以上が必要" , needed, expected, needed) } else { fmt.Printf( " → 必要数(%d) ≤ 2倍容量(%d) なので %d前後" , needed, expected, expected) } fmt.Printf( " → 実際は%d \n " , cap (s)) } } 実行結果 === Goスライスの容量拡張アルゴリズム検証 === 1. s4のケース分析: append(s4, 4, 5, 6, 7) s4 初期状態: len=3, cap=3 - 現在の要素数: 3 - 追加する要素数: 4個 - 必要な合計要素数: 3 + 4 = 7 - 容量を2倍にすると: 3 × 2 = 6 - 必要な要素数(7) > 2倍の容量(6) なので、容量は7以上が必要 s4 拡張後: len=7, cap=8 → 結果: 7が必要だが、実際は8になっている(メモリアライメント) 2. s5のケース分析: append(s5, 40) s5 初期状態: len=3, cap=3 - 現在の要素数: 3 - 追加する要素数: 1個 - 必要な合計要素数: 3 + 1 = 4 - 容量を2倍にすると: 3 × 2 = 6 - 必要な要素数(4) < 2倍の容量(6) なので、容量は6になる s5 拡張後: len=4, cap=6 → 結果: 予想通り6になっている 3. Goの容量拡張ルール: ステップ1: 新しい容量を決定 - 現在の容量を2倍する - もし必要な要素数が2倍容量より大きければ、必要な要素数を採用 ステップ2: メモリアライメント調整 - CPUとメモリの効率化のため、特定のサイズに調整される 4. さまざまなパターンでの検証 1つ追加 (初期容量:3): 追加前: len=3, cap=3 追加要素数: 1 必要合計要素数: 4 2倍容量: 6 結果: len=4, cap=6 → 必要数(4) ≤ 2倍容量(6) なので 6前後 → 実際は6 2つ追加 (初期容量:3): 追加前: len=3, cap=3 追加要素数: 2 必要合計要素数: 5 2倍容量: 6 結果: len=5, cap=6 → 必要数(5) ≤ 2倍容量(6) なので 6前後 → 実際は6 3つ追加 (初期容量:3): 追加前: len=3, cap=3 追加要素数: 3 必要合計要素数: 6 2倍容量: 6 結果: len=6, cap=6 → 必要数(6) ≤ 2倍容量(6) なので 6前後 → 実際は6 4つ追加 (初期容量:3): 追加前: len=3, cap=3 追加要素数: 4 必要合計要素数: 7 2倍容量: 6 結果: len=7, cap=8 → 必要数(7) > 2倍容量(6) なので 7以上が必要 → 実際は8 5つ追加 (初期容量:3): 追加前: len=3, cap=3 追加要素数: 5 必要合計要素数: 8 2倍容量: 6 結果: len=8, cap=8 → 必要数(8) > 2倍容量(6) なので 8以上が必要 → 実際は8 10つ追加 (初期容量:3): 追加前: len=3, cap=3 追加要素数: 10 必要合計要素数: 13 2倍容量: 6 結果: len=13, cap=14 → 必要数(13) > 2倍容量(6) なので 13以上が必要 → 実際は14 クイズ2 ## **🎯 クイズ第2問** 次の学習ステップとして、**スライスの容量拡張アルゴリズム**について深掘りしましょう。 **問題**: サンプル1の実行で、 ` s4 ` の容量が ` 3→8 ` 、 ` s5 ` の容量が ` 3→6 ` と異なる成長パターンを示しました。 **なぜ同じ初期容量3なのに、拡張後の容量が異なるのでしょうか?** ヒント: - ` s4 ` は ` append(s4, 4, 5, 6, 7) ` (4つの要素を追加) - ` s5 ` は ` append(s5, 40) ` (1つの要素を追加) - Goの容量拡張アルゴリズムを考えてみてください あなたの推理をお聞かせください! 次に、スライスの容量拡張アルゴリズムについて深掘りしました。 スライスの持つ容量を超えて要素を追加しようとする場合、内部的に容量が拡張されていきます。 この時、拡張率が1.25〜2倍程度に収まるように容量を拡張します 3 。 また、詳細は割愛しますが、最終的な容量はサイズクラス 4 というものに基づいて決定されます。 // nextslicecap computes the next appropriate slice length. func nextslicecap(newLen, oldCap int ) int { newcap := oldCap doublecap := newcap + newcap if newLen > doublecap { return newLen } const threshold = 256 if oldCap < threshold { return doublecap } for { // Transition from growing 2x for small slices // to growing 1.25x for large slices. This formula // gives a smooth-ish transition between the two. newcap += (newcap + 3 *threshold) >> 2 // We need to check `newcap >= newLen` and whether `newcap` overflowed. // newLen is guaranteed to be larger than zero, hence // when newcap overflows then `uint(newcap) > uint(newLen)`. // This allows to check for both with the same comparison. if uint (newcap) >= uint (newLen) { break } } // Set newcap to the requested cap when // the newcap calculation overflowed. if newcap <= 0 { return newLen } return newcap } 計算量とパフォーマンスの要点 サンプル4(実装+実行結果) 実装 package main import ( "fmt" "time" ) func main() { fmt.Println( "=== スライス操作の時間・空間計算量分析 === \n " ) // 1. append操作の償却計算量 fmt.Println( "1. append操作の償却計算量分析" ) measureAppendPerformance() // 2. 事前容量指定の効果 fmt.Println( " \n 2. 事前容量指定の効果" ) measurePreallocationEffect() // 3. スライシング操作の計算量 fmt.Println( " \n 3. スライシング操作の計算量" ) measureSlicingPerformance() // 4. copy操作の計算量 fmt.Println( " \n 4. copy操作の計算量" ) measureCopyPerformance() } // append操作の償却計算量を測定 func measureAppendPerformance() { sizes := [] int { 1000 , 10000 , 100000 , 1000000 } fmt.Println( "動的append(容量未指定):" ) for _, size := range sizes { start := time.Now() var s [] int for i := 0 ; i < size; i++ { s = append (s, i) } duration := time.Since(start) fmt.Printf( " 要素数 %7d: %v (1要素あたり: %v) \n " , size, duration, duration/time.Duration(size)) } fmt.Println( " \n 理論的分析:" ) fmt.Println( " - 各append操作: O(1) 償却時間" ) fmt.Println( " - n回のappend: O(n) 合計時間" ) fmt.Println( " - 容量拡張時のコピー: O(k) (kは現在の要素数)" ) fmt.Println( " - 拡張頻度: log(n) 回(容量が倍々で増加)" ) fmt.Println( " - 合計コピー時間: O(n) (幾何級数の和)" ) } // 事前容量指定の効果を測定 func measurePreallocationEffect() { size := 1000000 // 容量未指定 start := time.Now() var s1 [] int for i := 0 ; i < size; i++ { s1 = append (s1, i) } duration1 := time.Since(start) // 容量事前指定 start = time.Now() s2 := make ([] int , 0 , size) for i := 0 ; i < size; i++ { s2 = append (s2, i) } duration2 := time.Since(start) // インデックス直接代入 start = time.Now() s3 := make ([] int , size) for i := 0 ; i < size; i++ { s3[i] = i } duration3 := time.Since(start) fmt.Printf( "要素数 %d での比較: \n " , size) fmt.Printf( " 動的append: %v \n " , duration1) fmt.Printf( " 事前容量確保: %v (%.1fx速い) \n " , duration2, float64 (duration1)/ float64 (duration2)) fmt.Printf( " 直接代入: %v (%.1fx速い) \n " , duration3, float64 (duration1)/ float64 (duration3)) fmt.Println( " \n 理論的分析:" ) fmt.Println( " - 動的append: O(n) + 容量拡張コスト" ) fmt.Println( " - 事前容量確保: O(n) (容量拡張なし)" ) fmt.Println( " - 直接代入: O(n) (append関数コールオーバーヘッドなし)" ) } // スライシング操作の計算量を測定 func measureSlicingPerformance() { sizes := [] int { 1000 , 10000 , 100000 , 1000000 } fmt.Println( "スライシング操作:" ) for _, size := range sizes { // 大きなスライスを作成 s := make ([] int , size) for i := range s { s[i] = i } start := time.Now() // スライシング操作(多数回実行) iterations := 100000 for i := 0 ; i < iterations; i++ { _ = s[ 100 : size- 100 ] // スライシング } duration := time.Since(start) fmt.Printf( " 元サイズ %7d: %v (1操作あたり: %v) \n " , size, duration, duration/time.Duration(iterations)) } fmt.Println( " \n 理論的分析:" ) fmt.Println( " - スライシング操作: O(1)" ) fmt.Println( " - 新しいslice構造体を作成するだけ" ) fmt.Println( " - 元の配列をコピーしない" ) fmt.Println( " - ポインタ、長さ、容量の計算のみ" ) } // copy操作の計算量を測定 func measureCopyPerformance() { sizes := [] int { 1000 , 10000 , 100000 , 1000000 } fmt.Println( "copy操作:" ) for _, size := range sizes { src := make ([] int , size) dst := make ([] int , size) for i := range src { src[i] = i } start := time.Now() copy (dst, src) duration := time.Since(start) fmt.Printf( " 要素数 %7d: %v (1要素あたり: %v) \n " , size, duration, duration/time.Duration(size)) } fmt.Println( " \n 理論的分析:" ) fmt.Println( " - copy操作: O(n) (nはコピーする要素数)" ) fmt.Println( " - メモリ間の線形コピー" ) fmt.Println( " - ハードウェア最適化(memmove)により高速" ) } 実行結果 === スライス操作の時間・空間計算量分析 === 1. append操作の償却計算量分析 動的append(容量未指定): 要素数 1000: 52.625µs (1要素あたり: 52ns) 要素数 10000: 470.917µs (1要素あたり: 47ns) 要素数 100000: 4.316875ms (1要素あたり: 43ns) 要素数 1000000: 66.438667ms (1要素あたり: 66ns) 理論的分析: - 各append操作: O(1) 償却時間 - n回のappend: O(n) 合計時間 - 容量拡張時のコピー: O(k) (kは現在の要素数) - 拡張頻度: log(n) 回(容量が倍々で増加) - 合計コピー時間: O(n) (幾何級数の和) 2. 事前容量指定の効果 要素数 1000000 での比較: 動的append: 46.498333ms 事前容量確保: 105.046542ms (0.4x速い) 直接代入: 2.286375ms (20.3x速い) 理論的分析: - 動的append: O(n) + 容量拡張コスト - 事前容量確保: O(n) (容量拡張なし) - 直接代入: O(n) (append関数コールオーバーヘッドなし) 3. スライシング操作の計算量 スライシング操作: 元サイズ 1000: 96.375µs (1操作あたり: 0s) 元サイズ 10000: 82.917µs (1操作あたり: 0s) 元サイズ 100000: 123.167µs (1操作あたり: 1ns) 元サイズ 1000000: 179.917µs (1操作あたり: 1ns) 理論的分析: - スライシング操作: O(1) - 新しいslice構造体を作成するだけ - 元の配列をコピーしない - ポインタ、長さ、容量の計算のみ 4. copy操作の計算量 copy操作: 要素数 1000: 708ns (1要素あたり: 0s) 要素数 10000: 14.791µs (1要素あたり: 1ns) 要素数 100000: 220.584µs (1要素あたり: 2ns) 要素数 1000000: 1.174583ms (1要素あたり: 1ns) 理論的分析: - copy操作: O(n) (nはコピーする要素数) - メモリ間の線形コピー - ハードウェア最適化(memmove)により高速 続けて、スライスの作成方法別に処理時間を計測しパフォーマンス比較を実施しました。 サンプル4を実行することで、スライスの作成方法によって以下のようなパフォーマンス特性があると考えられることが確認できました。 初期化時容量指定なし&forループでappend: O(n) + append コールオーバーヘッド + 容量拡張 初期化時容量指定あり&forループでappend: O(n) + append コールオーバーヘッド 初期化時容量指定あり&forループでインデックス指定代入: O(n) スライシング ( s2 := s1[start:end] ): O(1) ポインタ、長さ、容量を計算して新しい slice を作成するだけ copy : O(n) ( n はコピーする要素数) memmove による最適化で高速 メモリ領域に依存(小さいスライスは速く、大きいスライスは遅くなる傾向) スライスが起こすメモリリーク サンプル5(実装+実行結果) 実装 package main import ( "fmt" "runtime" "time" "unsafe" ) // グローバル変数で保持することで、ガベージコレクションを防ぐ var globalSlices [][] int // 問題のある関数:大きな配列のうち一部だけを返す func createLeakySlice(size int ) [] int { fmt.Printf( " %d要素の配列を作成中... \n " , size) large := make ([] int , size) for i := range large { large[i] = i } // 最初の10要素だけを返す(メモリリーク!) small := large[: 10 ] fmt.Printf( " 返すスライス: len=%d, cap=%d \n " , len (small), cap (small)) return small } // 改善された関数:必要な部分だけをコピーして返す func createFixedSlice(size int ) [] int { fmt.Printf( " %d要素の配列を作成中... \n " , size) large := make ([] int , size) for i := range large { large[i] = i } // 必要な部分だけをコピー small := make ([] int , 10 ) copy (small, large[: 10 ]) fmt.Printf( " 返すスライス: len=%d, cap=%d \n " , len (small), cap (small)) return small // 関数終了時に large は解放される } func printMemoryStats(label string ) { runtime.GC() // 強制的にガベージコレクションを実行 runtime.GC() // 2回実行してより確実に time.Sleep( 100 * time.Millisecond) // GCの完了を待つ var m runtime.MemStats runtime.ReadMemStats(&m) fmt.Printf( "%s: \n " , label) fmt.Printf( " Alloc: %.2f MB (現在使用中のメモリ) \n " , float64 (m.Alloc)/ 1024 / 1024 ) fmt.Printf( " TotalAlloc: %.2f MB (累計割り当てメモリ) \n " , float64 (m.TotalAlloc)/ 1024 / 1024 ) fmt.Printf( " Sys: %.2f MB (システムから取得したメモリ) \n " , float64 (m.Sys)/ 1024 / 1024 ) fmt.Printf( " NumGC: %d (ガベージコレクション回数) \n " , m.NumGC) fmt.Println() } func main() { fmt.Println( "=== メモリリーク実証実験 === \n " ) printMemoryStats( "初期状態" ) // 実験1: メモリリークあり(大きなサイズで確実に確認) fmt.Println( "=== 実験1: メモリリークあり(問題のあるパターン) ===" ) // 複数の大きなスライスを作成して保持 sizes := [] int { 1000000 , 2000000 , 3000000 } // 1M, 2M, 3M要素 for i, size := range sizes { fmt.Printf( "ステップ %d: %d要素の配列から10要素スライスを作成 \n " , i+ 1 , size) leakySlice := createLeakySlice(size) // グローバル変数に保存してガベージコレクションを防ぐ globalSlices = append (globalSlices, leakySlice) printMemoryStats(fmt.Sprintf( "ステップ %d 後" , i+ 1 )) // 同じ配列を参照していることを確認 fmt.Printf( " 参照先確認: 容量=%d (元の配列サイズと同じ=%t) \n " , cap (leakySlice), cap (leakySlice) == size) fmt.Println( " → 大きな配列全体がメモリに残っている! \n " ) } fmt.Printf( "問題パターン合計: %d個のスライスを保持 \n " , len (globalSlices)) fmt.Printf( "実際に使用している要素数: %d \n " , len (globalSlices)* 10 ) totalCapacity := 0 for _, s := range globalSlices { totalCapacity += cap (s) } fmt.Printf( "実際に確保されている容量: %d (%.0f倍の無駄) \n " , totalCapacity, float64 (totalCapacity)/ float64 ( len (globalSlices)* 10 )) printMemoryStats( "問題パターン完了後" ) // グローバル変数をクリア globalSlices = nil printMemoryStats( "問題パターンクリア後" ) // 実験2: メモリリークなし fmt.Println( "=== 実験2: メモリリークなし(改善されたパターン) ===" ) var fixedSlices [][] int for i, size := range sizes { fmt.Printf( "ステップ %d: %d要素の配列から10要素スライスを作成(コピー版) \n " , i+ 1 , size) fixedSlice := createFixedSlice(size) fixedSlices = append (fixedSlices, fixedSlice) printMemoryStats(fmt.Sprintf( "改善ステップ %d 後" , i+ 1 )) fmt.Printf( " 参照先確認: 容量=%d (最小限=%t) \n " , cap (fixedSlice), cap (fixedSlice) == 10 ) fmt.Println( " → 必要な分だけメモリを使用! \n " ) } printMemoryStats( "改善パターン完了後" ) // 実験3: 実際のメモリアドレスの比較 fmt.Println( "=== 実験3: メモリアドレス比較実験 ===" ) demonstrateAddressComparison() } func demonstrateAddressComparison() { // 大きな配列を作成 bigArray := make ([] int , 1000000 ) for i := range bigArray { bigArray[i] = i } fmt.Printf( "元の大きな配列のアドレス: 0x%x \n " , uintptr ( unsafe.Pointer (&bigArray[ 0 ]))) // 問題のあるスライシング problematicSlice := bigArray[: 5 ] fmt.Printf( "問題スライスのアドレス: 0x%x (同じ=%t) \n " , uintptr ( unsafe.Pointer (&problematicSlice[ 0 ])), unsafe.Pointer (&bigArray[ 0 ]) == unsafe.Pointer (&problematicSlice[ 0 ])) // 改善されたコピー fixedSlice := make ([] int , 5 ) copy (fixedSlice, bigArray[: 5 ]) fmt.Printf( "改善スライスのアドレス: 0x%x (異なる=%t) \n " , uintptr ( unsafe.Pointer (&fixedSlice[ 0 ])), unsafe.Pointer (&bigArray[ 0 ]) != unsafe.Pointer (&fixedSlice[ 0 ])) fmt.Println( " \n メモリ参照の違い:" ) fmt.Printf( "問題スライス: len=%d, cap=%d (大きな配列を参照) \n " , len (problematicSlice), cap (problematicSlice)) fmt.Printf( "改善スライス: len=%d, cap=%d (独立した小さな配列) \n " , len (fixedSlice), cap (fixedSlice)) // bigArrayをnilにしても、problematicSliceが参照を保持 fmt.Println( " \n 元の配列をnilにした場合:" ) bigArray = nil runtime.GC() fmt.Printf( "problematicSlice[0] = %d (まだアクセス可能) \n " , problematicSlice[ 0 ]) fmt.Printf( "容量は依然として: %d \n " , cap (problematicSlice)) fmt.Println( "→ 大きな配列は解放されていない!" ) } 実行結果 === メモリリーク実証実験 === 初期状態: Alloc: 0.11 MB (現在使用中のメモリ) TotalAlloc: 0.12 MB (累計割り当てメモリ) Sys: 6.52 MB (システムから取得したメモリ) NumGC: 2 (ガベージコレクション回数) === 実験1: メモリリークあり(問題のあるパターン) === ステップ 1: 1000000要素の配列から10要素スライスを作成 1000000要素の配列を作成中... 返すスライス: len=10, cap=1000000 ステップ 1 後: Alloc: 7.75 MB (現在使用中のメモリ) TotalAlloc: 7.75 MB (累計割り当てメモリ) Sys: 14.52 MB (システムから取得したメモリ) NumGC: 5 (ガベージコレクション回数) 参照先確認: 容量=1000000 (元の配列サイズと同じ=true) → 大きな配列全体がメモリに残っている! ステップ 2: 2000000要素の配列から10要素スライスを作成 2000000要素の配列を作成中... 返すスライス: len=10, cap=2000000 ステップ 2 後: Alloc: 23.02 MB (現在使用中のメモリ) TotalAlloc: 23.02 MB (累計割り当てメモリ) Sys: 30.52 MB (システムから取得したメモリ) NumGC: 8 (ガベージコレクション回数) 参照先確認: 容量=2000000 (元の配列サイズと同じ=true) → 大きな配列全体がメモリに残っている! ステップ 3: 3000000要素の配列から10要素スライスを作成 3000000要素の配列を作成中... 返すスライス: len=10, cap=3000000 ステップ 3 後: Alloc: 45.91 MB (現在使用中のメモリ) TotalAlloc: 45.92 MB (累計割り当てメモリ) Sys: 54.52 MB (システムから取得したメモリ) NumGC: 11 (ガベージコレクション回数) 参照先確認: 容量=3000000 (元の配列サイズと同じ=true) → 大きな配列全体がメモリに残っている! 問題パターン合計: 3個のスライスを保持 実際に使用している要素数: 30 実際に確保されている容量: 6000000 (200000倍の無駄) 問題パターン完了後: Alloc: 45.91 MB (現在使用中のメモリ) TotalAlloc: 45.92 MB (累計割り当てメモリ) Sys: 54.52 MB (システムから取得したメモリ) NumGC: 13 (ガベージコレクション回数) 問題パターンクリア後: Alloc: 0.12 MB (現在使用中のメモリ) TotalAlloc: 45.92 MB (累計割り当てメモリ) Sys: 54.52 MB (システムから取得したメモリ) NumGC: 15 (ガベージコレクション回数) === 実験2: メモリリークなし(改善されたパターン) === ステップ 1: 1000000要素の配列から10要素スライスを作成(コピー版) 1000000要素の配列を作成中... 返すスライス: len=10, cap=10 改善ステップ 1 後: Alloc: 0.12 MB (現在使用中のメモリ) TotalAlloc: 53.56 MB (累計割り当てメモリ) Sys: 54.77 MB (システムから取得したメモリ) NumGC: 18 (ガベージコレクション回数) 参照先確認: 容量=10 (最小限=true) → 必要な分だけメモリを使用! ステップ 2: 2000000要素の配列から10要素スライスを作成(コピー版) 2000000要素の配列を作成中... 返すスライス: len=10, cap=10 改善ステップ 2 後: Alloc: 0.12 MB (現在使用中のメモリ) TotalAlloc: 68.83 MB (累計割り当てメモリ) Sys: 54.77 MB (システムから取得したメモリ) NumGC: 21 (ガベージコレクション回数) 参照先確認: 容量=10 (最小限=true) → 必要な分だけメモリを使用! ステップ 3: 3000000要素の配列から10要素スライスを作成(コピー版) 3000000要素の配列を作成中... 返すスライス: len=10, cap=10 改善ステップ 3 後: Alloc: 0.12 MB (現在使用中のメモリ) TotalAlloc: 91.72 MB (累計割り当てメモリ) Sys: 54.77 MB (システムから取得したメモリ) NumGC: 24 (ガベージコレクション回数) 参照先確認: 容量=10 (最小限=true) → 必要な分だけメモリを使用! 改善パターン完了後: Alloc: 0.12 MB (現在使用中のメモリ) TotalAlloc: 91.72 MB (累計割り当てメモリ) Sys: 54.77 MB (システムから取得したメモリ) NumGC: 26 (ガベージコレクション回数) === 実験3: メモリアドレス比較実験 === 元の大きな配列のアドレス: 0x400018c000 問題スライスのアドレス: 0x400018c000 (同じ=true) 改善スライスのアドレス: 0x40000d7c08 (異なる=true) メモリ参照の違い: 問題スライス: len=5, cap=1000000 (大きな配列を参照) 改善スライス: len=5, cap=5 (独立した小さな配列) 元の配列をnilにした場合: problematicSlice[0] = 0 (まだアクセス可能) 容量は依然として: 1000000 → 大きな配列は解放されていない! クイズ3 ## **🎯 クイズ第3問** 今度は、スライスの**メモリリークと共有配列の問題**について学習しましょう。 サンプル5の実行結果と問題点を予想してください: **質問:** 1. ` small ` の長さと容量はいくつになるでしょうか? 2. 100万要素の大きな配列はガベージコレクションされるでしょうか? 3. もしガベージコレクションされない場合、なぜでしょうか? これは実際の開発でよく遭遇する**メモリリーク**の原因となる重要な問題です。あなたの予想をお聞かせください! ここでは、スライス起因のメモリリークについて検証しました。 (確保メモリ領域の解放忘れではなく slice の参照による残存なので、 厳密にはメモリリークとは言わないかもしれませんが、ここではメモリリークという文脈で話を進めていきます。) このステップの検証から、以下のような考察を得ることができました。 スライスは配列への参照であり、部分スライスが生存している限り、元配列は GC で解放されない。 スライスが小さくても大きい配列を参照している場合は、配列が確保している大きなメモリ領域は解放されない。 参照が残らなければ良いので、 copy 関数などを利用することで回避可能 文字列におけるメモリリーク サンプル6(実装+実行結果) 実装 package main import ( "bufio" "fmt" "os" "runtime" "strings" "sync" "time" "unsafe" ) // 大きなテストファイルを作成(約50MB) func createLargeTestLogFile() { file, err := os.Create( "large_test.log" ) if err != nil { fmt.Printf( "ファイル作成エラー: %v \n " , err) return } defer file.Close() // 約50MBのログファイルを生成 for i := 0 ; i < 500000 ; i++ { if i% 10000 == 0 { file.WriteString(fmt.Sprintf( "ERROR: Critical error occurred at line %d - %s \n " , i, strings.Repeat( "important data " , 10 ))) } else { file.WriteString(fmt.Sprintf( "INFO: Normal operation at line %d - %s \n " , i, strings.Repeat( "lorem ipsum dolor sit amet consectetur adipiscing elit " , 5 ))) } } fmt.Printf( "大きなテストファイル (large_test.log) を作成しました \n " ) } // 問題のある元のコード(より明確に問題を示すバージョン) func processLogFileProblematic(filename string ) [] string { content, err := os.ReadFile(filename) if err != nil { return nil } // 重要: 文字列変換で元のバイト配列とは別に文字列用メモリを確保 contentStr := string (content) lines := strings.Split(contentStr, " \n " ) var errorLines [] string for _, line := range lines { if strings.Contains(line, "ERROR" ) { // ここが問題: line は contentStr への参照 errorLines = append (errorLines, line) } } // content と contentStr はクリアしても、 // errorLines の各要素が contentStr への参照を保持 return errorLines } // 改善策1: ストリーミング処理 func processLogFileStreaming(filename string ) [] string { file, err := os.Open(filename) if err != nil { return nil } defer file.Close() var errorLines [] string scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() if strings.Contains(line, "ERROR" ) { // strings.Clone で元ファイルへの参照を切断 errorLines = append (errorLines, strings.Clone(line)) } } return errorLines } // 改善策4-1: ファイル分割処理 func processLogFileChunked(filename string , chunkSize int64 ) [] string { file, err := os.Open(filename) if err != nil { return nil } defer file.Close() var errorLines [] string // ファイルを指定サイズのチャンクに分割して処理 buffer := make ([] byte , chunkSize) var remainder [] byte for { n, err := file.Read(buffer) if n == 0 { break } // 前回の余りと今回読んだデータを結合 chunk := append (remainder, buffer[:n]...) // 最後の不完全な行を次回に持ち越し lastNewline := strings.LastIndex( string (chunk), " \n " ) if lastNewline == - 1 { remainder = chunk continue } processChunk := chunk[:lastNewline] remainder = chunk[lastNewline+ 1 :] // チャンク内でエラー行を検索 lines := strings.Split( string (processChunk), " \n " ) for _, line := range lines { if strings.Contains(line, "ERROR" ) { errorLines = append (errorLines, strings.Clone(line)) } } if err != nil { break } } // 最後の余りも処理 if len (remainder) > 0 && strings.Contains( string (remainder), "ERROR" ) { errorLines = append (errorLines, strings.Clone( string (remainder))) } return errorLines } // 改善策4-2: 並列処理 func processLogFileParallel(filename string ) [] string { content, err := os.ReadFile(filename) if err != nil { return nil } lines := strings.Split( string (content), " \n " ) // ワーカー数 numWorkers := runtime.NumCPU() chunkSize := len (lines) / numWorkers var wg sync.WaitGroup var mu sync.Mutex var errorLines [] string for i := 0 ; i < numWorkers; i++ { wg.Add( 1 ) go func (start, end int ) { defer wg.Done() var localErrors [] string for j := start; j < end && j < len (lines); j++ { if strings.Contains(lines[j], "ERROR" ) { localErrors = append (localErrors, strings.Clone(lines[j])) } } mu.Lock() errorLines = append (errorLines, localErrors...) mu.Unlock() }(i*chunkSize, (i+ 1 )*chunkSize) } wg.Wait() return errorLines } // 改善策4-3: インデックス利用 type LogIndex struct { ErrorLineNumbers [] int filename string } func buildLogIndex(filename string ) *LogIndex { file, err := os.Open(filename) if err != nil { return nil } defer file.Close() var errorLines [] int scanner := bufio.NewScanner(file) lineNumber := 0 for scanner.Scan() { if strings.Contains(scanner.Text(), "ERROR" ) { errorLines = append (errorLines, lineNumber) } lineNumber++ } return &LogIndex{ ErrorLineNumbers: errorLines, filename: filename, } } func (idx *LogIndex) GetErrorLines() [] string { file, err := os.Open(idx.filename) if err != nil { return nil } defer file.Close() var errorLines [] string scanner := bufio.NewScanner(file) lineNumber := 0 errorIndex := 0 for scanner.Scan() && errorIndex < len (idx.ErrorLineNumbers) { if lineNumber == idx.ErrorLineNumbers[errorIndex] { errorLines = append (errorLines, strings.Clone(scanner.Text())) errorIndex++ } lineNumber++ } return errorLines } func printMemoryStats(label string ) { runtime.GC() runtime.GC() time.Sleep( 100 * time.Millisecond) var m runtime.MemStats runtime.ReadMemStats(&m) fmt.Printf( "%s: %.2f MB \n " , label, float64 (m.Alloc)/ 1024 / 1024 ) } func demonstrateMemoryLeak() { fmt.Println( "=== 実際のメモリリーク実証(大きなファイル使用) ===" ) printMemoryStats( "初期状態" ) // 問題のあるバージョンでメモリリークを実証 fmt.Println( "問題のあるバージョン実行..." ) // メモリリークを維持するために結果を保持 var leakyResults [] string leakyResults = processLogFileProblematic( "large_test.log" ) fmt.Printf( "エラー行数: %d \n " , len (leakyResults)) printMemoryStats( "問題版実行後(結果保持中)" ) // 結果の文字列が元ファイルへの参照を保持していることを証明 fmt.Printf( "最初のエラー行のアドレス: 0x%x \n " , (*(* unsafe.Pointer )( unsafe.Pointer (&leakyResults[ 0 ])))) // leakyResultsをクリアするまでメモリは解放されない fmt.Println( "結果をクリアします..." ) leakyResults = nil printMemoryStats( "問題版結果クリア後" ) // 改善版の実行 fmt.Println( "改善版(ストリーミング)実行..." ) streamingResults := processLogFileStreaming( "large_test.log" ) fmt.Printf( "エラー行数: %d \n " , len (streamingResults)) printMemoryStats( "改善版実行後" ) fmt.Printf( "改善版エラー行のアドレス: 0x%x \n " , (*(* unsafe.Pointer )( unsafe.Pointer (&streamingResults[ 0 ])))) fmt.Println( "→ 異なるアドレス = 独立した文字列" ) } func compareAllApproaches() { fmt.Println( " \n === 全改善策のパフォーマンス比較 ===" ) approaches := [] struct { name string fn func ( string ) [] string }{ { "問題版" , processLogFileProblematic}, { "ストリーミング版" , processLogFileStreaming}, { "チャンク分割版" , func (f string ) [] string { return processLogFileChunked(f, 1024 * 1024 ) }}, // 1MB chunks { "並列処理版" , processLogFileParallel}, } for _, approach := range approaches { start := time.Now() result := approach.fn( "large_test.log" ) duration := time.Since(start) fmt.Printf( "%-12s: %v (%d行) \n " , approach.name, duration, len (result)) } // インデックス利用のテスト fmt.Println( " \n インデックス利用版:" ) start := time.Now() index := buildLogIndex( "large_test.log" ) indexBuildTime := time.Since(start) start = time.Now() indexResults := index.GetErrorLines() queryTime := time.Since(start) fmt.Printf( " インデックス構築: %v \n " , indexBuildTime) fmt.Printf( " クエリ実行: %v (%d行) \n " , queryTime, len (indexResults)) fmt.Printf( " 合計時間: %v \n " , indexBuildTime+queryTime) } func main() { fmt.Println( "=== 改善版分析とメモリリーク実証 === \n " ) // 大きなテストファイルを作成 createLargeTestLogFile() // 実際のメモリリークを実証 demonstrateMemoryLeak() // 全改善策の比較 compareAllApproaches() // 改善策の詳細解説 explainAdvancedStrategies() } func explainAdvancedStrategies() { fmt.Println( " \n === 改善策4の詳細解説 ===" ) fmt.Println( "1. ファイル分割処理:" ) fmt.Println( " ✅ ファイルを小さなチャンク(例:1MB)に分割" ) fmt.Println( " ✅ メモリ使用量を一定に保持" ) fmt.Println( " ✅ 巨大ファイルでも処理可能" ) fmt.Println( " ⚠️ 行の境界を適切に処理する必要" ) fmt.Println( " \n 2. 並列処理:" ) fmt.Println( " ✅ CPUコア数に応じてワーカーを起動" ) fmt.Println( " ✅ ファイルを分割して並列検索" ) fmt.Println( " ✅ マルチコアCPUで高速化" ) fmt.Println( " ⚠️ メモリ使用量は増加" ) fmt.Println( " \n 3. インデックス利用:" ) fmt.Println( " ✅ 事前にエラー行の位置をインデックス化" ) fmt.Println( " ✅ 同じファイルを何度も検索する場合に効率的" ) fmt.Println( " ✅ 部分的な結果取得が高速" ) fmt.Println( " ⚠️ インデックス構築時間が必要" ) fmt.Println( " \n 4. その他の高度な手法:" ) fmt.Println( " - Memory-mapped files (mmap)" ) fmt.Println( " - 圧縮ファイルの直接処理" ) fmt.Println( " - データベースへの事前インポート" ) fmt.Println( " - 正規表現の最適化" ) } 実行結果 === 改善版分析とメモリリーク実証 === 大きなテストファイル (large _ test.log) を作成しました === 実際のメモリリーク実証(大きなファイル使用) === 初期状態: 0.12 MB 問題のあるバージョン実行... エラー行数: 50 問題版実行後(結果保持中): 150.69 MB 最初のエラー行のアドレス: 0x4009c12000 結果をクリアします... 問題版結果クリア後: 0.12 MB 改善版(ストリーミング)実行... エラー行数: 50 改善版実行後: 0.13 MB 改善版エラー行のアドレス: 0x400009e0d0 → 異なるアドレス = 独立した文字列 === 全改善策のパフォーマンス比較 === 問題版 : 325.162209ms (50行) ストリーミング版 : 94.33525ms (50行) チャンク分割版 : 253.834459ms (50行) 並列処理版 : 852.810793ms (50行) インデックス利用版: インデックス構築: 1.729318959s クエリ実行: 107.052958ms (50行) 合計時間: 1.836371917s === 改善策4の詳細解説 === 1. ファイル分割処理: ✅ ファイルを小さなチャンク(例:1MB)に分割 ✅ メモリ使用量を一定に保持 ✅ 巨大ファイルでも処理可能 ⚠️ 行の境界を適切に処理する必要 2. 並列処理: ✅ CPUコア数に応じてワーカーを起動 ✅ ファイルを分割して並列検索 ✅ マルチコアCPUで高速化 ⚠️ メモリ使用量は増加 3. インデックス利用: ✅ 事前にエラー行の位置をインデックス化 ✅ 同じファイルを何度も検索する場合に効率的 ✅ 部分的な結果取得が高速 ⚠️ インデックス構築時間が必要 4. その他の高度な手法: - Memory-mapped files (mmap) - 圧縮ファイルの直接処理 - データベースへの事前インポート - 正規表現の最適化 クイズ4 ## **🎯 最終学習確認クイズ** 素晴らしい理解力を示していただいたので、最終問題に進みましょう! **問題**: 問題のあるコード例の問題点と改善策を説明してください: **分析項目:** 1. **メモリ効率性**の問題点 2. **時間計算量**の問題点 3. **具体的な改善策**を2つ以上 あなたのスライス学習の集大成として、分析をお聞かせください! スライスのメモリリークの話から発展して、文字列のメモリリークについての検証も実施しました。 ここでは、問題のあるコード(以下参照)が提示され、その問題点と改善策を考えさせられました。 func processLogFile(filename string ) [] string { // 10MB のログファイルを読み込み content, _ := os.ReadFile(filename) // 10MB lines := strings.Split( string (content), " \n " ) // 100万行 // エラー行だけを抽出(通常は数行) var errorLines [] string for _, line := range lines { if strings.Contains(line, "ERROR" ) { errorLines = append (errorLines, line) } } return errorLines // 通常は2-3行 } Go の string 型は、内部的には「実データバイト列へのポインタ」と「長さ」を持つ構造体です 5 。 type stringStruct struct { str unsafe.Pointer len int } 実際に、以下のようなコードで string 型の変数を for 文でループすることも可能です。 func forString() { a := "hello, world" for i, s := range a { fmt.Println(i, s) } } 上記を踏まえると、このコードについて以下のような問題点と改善策が考えられます。 問題点: os.ReadFile で大きなファイルの全体を一度に読み込んでいる strings.Split の各行が元の文字列を参照するので、関数終了後もメモリに残存し、GCで解放されない ループで大きなスライスの要素全てを走査している 改善策: 長さが既知の場合は事前に容量を確保することで再割り当てを削減 並列処理 構造体を導入して対象となるインデックスを保持することでインデックス参照する 部分的な結果取得や繰り返し検索に有効 チャンク処理 ストリーミング処理 多次元スライスのループ処理 サンプル7(実装+実行結果) 実装 package main import ( "fmt" "math/rand" "runtime" "time" "unsafe" ) // 計算量の分析とパフォーマンス測定を行う関数 func measurePerformance(name string , fn func ()) time.Duration { runtime.GC() // 測定前にGCを実行 start := time.Now() fn() duration := time.Since(start) fmt.Printf( "%-25s: %v \n " , name, duration) return duration } // 1. O(n^2) - 基本的な2重ループ func basicNestedLoop(size int ) { data := make ([][] int , size) for i := range data { data[i] = make ([] int , size) } // 非効率なアクセスパターン(列優先) for j := 0 ; j < size; j++ { for i := 0 ; i < size; i++ { data[i][j] = i + j } } } // 2. 改善版 - キャッシュ効率の良いアクセスパターン func optimizedNestedLoop(size int ) { data := make ([][] int , size) for i := range data { data[i] = make ([] int , size) } // 効率的なアクセスパターン(行優先) for i := 0 ; i < size; i++ { for j := 0 ; j < size; j++ { data[i][j] = i + j } } } // 3. 1次元スライスを使用した最適化 func flattenedArrayOptimization(size int ) { // 2次元配列を1次元配列として表現 data := make ([] int , size*size) for i := 0 ; i < size; i++ { for j := 0 ; j < size; j++ { // 2次元座標を1次元インデックスに変換 data[i*size+j] = i + j } } } // 4. O(n^3) - 行列乗算の例 func matrixMultiplication(a, b [][] int , size int ) [][] int { result := make ([][] int , size) for i := range result { result[i] = make ([] int , size) } // 標準的な行列乗算 O(n^3) for i := 0 ; i < size; i++ { for j := 0 ; j < size; j++ { for k := 0 ; k < size; k++ { result[i][j] += a[i][k] * b[k][j] } } } return result } // 5. 最適化された行列乗算(ループ順序の改善) func optimizedMatrixMultiplication(a, b [][] int , size int ) [][] int { result := make ([][] int , size) for i := range result { result[i] = make ([] int , size) } // ループ順序を変更してキャッシュ効率を改善 for i := 0 ; i < size; i++ { for k := 0 ; k < size; k++ { for j := 0 ; j < size; j++ { result[i][j] += a[i][k] * b[k][j] } } } return result } // 6. ブロック化による最適化 func blockedMatrixMultiplication(a, b [][] int , size, blockSize int ) [][] int { result := make ([][] int , size) for i := range result { result[i] = make ([] int , size) } // ブロック単位で処理してキャッシュ効率を最大化 for ii := 0 ; ii < size; ii += blockSize { for jj := 0 ; jj < size; jj += blockSize { for kk := 0 ; kk < size; kk += blockSize { // ブロック内の処理 maxI := min(ii+blockSize, size) maxJ := min(jj+blockSize, size) maxK := min(kk+blockSize, size) for i := ii; i < maxI; i++ { for j := jj; j < maxJ; j++ { for k := kk; k < maxK; k++ { result[i][j] += a[i][k] * b[k][j] } } } } } } return result } // 7. 並列化による最適化 func parallelMatrixMultiplication(a, b [][] int , size int ) [][] int { result := make ([][] int , size) for i := range result { result[i] = make ([] int , size) } numWorkers := runtime.NumCPU() rowsPerWorker := size / numWorkers done := make ( chan bool , numWorkers) for worker := 0 ; worker < numWorkers; worker++ { startRow := worker * rowsPerWorker endRow := startRow + rowsPerWorker if worker == numWorkers- 1 { endRow = size // 最後のワーカーは残りを全て処理 } go func (start, end int ) { for i := start; i < end; i++ { for j := 0 ; j < size; j++ { for k := 0 ; k < size; k++ { result[i][j] += a[i][k] * b[k][j] } } } done <- true }(startRow, endRow) } // 全ワーカーの完了を待機 for i := 0 ; i < numWorkers; i++ { <-done } return result } // 8. メモリアクセスパターンの分析 func analyzeMemoryAccessPatterns() { fmt.Println( "=== メモリアクセスパターンの分析 ===" ) size := 1000 // 行優先アクセス data := make ([][] int , size) for i := range data { data[i] = make ([] int , size) } fmt.Printf( "2次元スライスのメモリレイアウト分析: \n " ) fmt.Printf( "data配列のアドレス: 0x%x \n " , uintptr ( unsafe.Pointer (&data[ 0 ]))) for i := 0 ; i < 3 ; i++ { fmt.Printf( "data[%d]のアドレス: 0x%x \n " , i, uintptr ( unsafe.Pointer (&data[i][ 0 ]))) if i > 0 { prevAddr := uintptr ( unsafe.Pointer (&data[i- 1 ][ 0 ])) currAddr := uintptr ( unsafe.Pointer (&data[i][ 0 ])) fmt.Printf( " 前の行との差: %d バイト \n " , currAddr-prevAddr) } } // 1次元配列での連続アクセス flatData := make ([] int , size*size) fmt.Printf( " \n 1次元スライスのメモリレイアウト: \n " ) fmt.Printf( "flatData[0]のアドレス: 0x%x \n " , uintptr ( unsafe.Pointer (&flatData[ 0 ]))) fmt.Printf( "flatData[1000]のアドレス: 0x%x \n " , uintptr ( unsafe.Pointer (&flatData[ 1000 ]))) fmt.Printf( "連続する要素の差: %d バイト \n " , uintptr ( unsafe.Pointer (&flatData[ 1 ]))- uintptr ( unsafe.Pointer (&flatData[ 0 ]))) } // 計算量の実証実験 func demonstrateComplexityGrowth() { fmt.Println( " \n === 計算量の実証実験 ===" ) sizes := [] int { 100 , 200 , 400 , 800 } fmt.Println( "基本的な2重ループ O(n^2):" ) var prevDuration time.Duration for i, size := range sizes { duration := measurePerformance(fmt.Sprintf( "サイズ %d" , size), func () { basicNestedLoop(size) }) if i > 0 { ratio := float64 (duration) / float64 (prevDuration) theoretical := float64 (size*size) / float64 (sizes[i- 1 ]*sizes[i- 1 ]) fmt.Printf( " 実測比率: %.2fx, 理論値(n^2): %.2fx \n " , ratio, theoretical) } prevDuration = duration } fmt.Println( " \n 行列乗算 O(n^3):" ) prevDuration = 0 for i, size := range sizes[: 3 ] { // サイズを制限(時間がかかるため) a := createRandomMatrix(size) b := createRandomMatrix(size) duration := measurePerformance(fmt.Sprintf( "サイズ %d" , size), func () { matrixMultiplication(a, b, size) }) if i > 0 { ratio := float64 (duration) / float64 (prevDuration) theoretical := float64 (size*size*size) / float64 (sizes[i- 1 ]*sizes[i- 1 ]*sizes[i- 1 ]) fmt.Printf( " 実測比率: %.2fx, 理論値(n^3): %.2fx \n " , ratio, theoretical) } prevDuration = duration } } // キャッシュ効率の比較実験 func compareCacheEfficiency() { fmt.Println( " \n === キャッシュ効率の比較 ===" ) size := 1000 fmt.Println( "2次元配列アクセスパターンの比較:" ) // 非効率なパターン(列優先) measurePerformance( "列優先アクセス(非効率)" , func () { basicNestedLoop(size) }) // 効率的なパターン(行優先) measurePerformance( "行優先アクセス(効率的)" , func () { optimizedNestedLoop(size) }) // 1次元配列パターン measurePerformance( "1次元配列(最適)" , func () { flattenedArrayOptimization(size) }) } // 行列乗算の最適化手法比較 func compareMatrixOptimizations() { fmt.Println( " \n === 行列乗算の最適化手法比較 ===" ) size := 256 blockSize := 32 a := createRandomMatrix(size) b := createRandomMatrix(size) fmt.Printf( "行列サイズ: %dx%d \n " , size, size) // 標準的な実装 measurePerformance( "標準実装 (i-j-k)" , func () { matrixMultiplication(a, b, size) }) // ループ順序最適化 measurePerformance( "ループ順序最適化 (i-k-j)" , func () { optimizedMatrixMultiplication(a, b, size) }) // ブロック化 measurePerformance( "ブロック化" , func () { blockedMatrixMultiplication(a, b, size, blockSize) }) // 並列化 measurePerformance( "並列化" , func () { parallelMatrixMultiplication(a, b, size) }) } // ランダムな行列を生成 func createRandomMatrix(size int ) [][] int { matrix := make ([][] int , size) for i := range matrix { matrix[i] = make ([] int , size) for j := range matrix[i] { matrix[i][j] = rand.Intn( 100 ) } } return matrix } // min関数(Go 1.21未満での互換性) func min(a, b int ) int { if a < b { return a } return b } // 最適化のベストプラクティス解説 func explainOptimizationPrinciples() { fmt.Println( " \n === ネストループ最適化の原則 ===" ) fmt.Println( "1. 時間計算量の理解:" ) fmt.Println( " - O(n): 線形時間" ) fmt.Println( " - O(n^2): 2重ループ - データサイズ2倍で処理時間4倍" ) fmt.Println( " - O(n^3): 3重ループ - データサイズ2倍で処理時間8倍" ) fmt.Println( " \n 2. キャッシュ効率の最適化:" ) fmt.Println( " - CPUキャッシュライン(通常64バイト)を意識" ) fmt.Println( " - 連続したメモリアクセスを優先" ) fmt.Println( " - 行優先アクセス(row-major)の採用" ) fmt.Println( " \n 3. ループ順序の最適化:" ) fmt.Println( " - 内側ループで連続メモリアクセス" ) fmt.Println( " - 一時変数の活用" ) fmt.Println( " - 不要な計算の外側への移動" ) fmt.Println( " \n 4. データ構造の最適化:" ) fmt.Println( " - 2次元配列 → 1次元配列(フラット化)" ) fmt.Println( " - 構造体配列 → 配列の構造体(AoS → SoA)" ) fmt.Println( " - メモリ局所性の向上" ) fmt.Println( " \n 5. アルゴリズムレベルの改善:" ) fmt.Println( " - ブロック化(タイリング)" ) fmt.Println( " - 並列化" ) fmt.Println( " - 計算量の削減(O(n^3) → O(n^2.8)など)" ) } func main() { fmt.Println( "=== 深いネストループ処理の負荷分析と最適化 === \n " ) // ランダムシードを設定 rand.Seed(time.Now().UnixNano()) // 1. メモリアクセスパターンの分析 analyzeMemoryAccessPatterns() // 2. 計算量の実証実験 demonstrateComplexityGrowth() // 3. キャッシュ効率の比較 compareCacheEfficiency() // 4. 行列乗算の最適化比較 compareMatrixOptimizations() // 5. 最適化原則の解説 explainOptimizationPrinciples() } 実行結果 === 深いネストループ処理の負荷分析と最適化 === === メモリアクセスパターンの分析 === 2次元スライスのメモリレイアウト分析: data配列のアドレス: 0x40000b8008 data[0]のアドレス: 0x40000be000 data[1]のアドレス: 0x40000c0000 前の行との差: 8192 バイト data[2]のアドレス: 0x40000c2000 前の行との差: 8192 バイト 1次元スライスのメモリレイアウト: flatData[0]のアドレス: 0x4000900000 flatData[1000]のアドレス: 0x4000901f40 連続する要素の差: 8 バイト === 計算量の実証実験 === 基本的な2重ループ O(n^2): サイズ 100 : 19.416µs サイズ 200 : 125.584µs 実測比率: 6.47x, 理論値(n^2): 4.00x サイズ 400 : 1.697583ms 実測比率: 13.52x, 理論値(n^2): 4.00x サイズ 800 : 6.607292ms 実測比率: 3.89x, 理論値(n^2): 4.00x 行列乗算 O(n^3): サイズ 100 : 2.359708ms サイズ 200 : 20.511333ms 実測比率: 8.69x, 理論値(n^3): 8.00x サイズ 400 : 198.047333ms 実測比率: 9.66x, 理論値(n^3): 8.00x === キャッシュ効率の比較 === 2次元配列アクセスパターンの比較: 列優先アクセス(非効率) : 12.861208ms 行優先アクセス(効率的) : 1.88625ms 1次元配列(最適) : 3.14025ms === 行列乗算の最適化手法比較 === 行列サイズ: 256x256 標準実装 (i-j-k) : 44.545958ms ループ順序最適化 (i-k-j) : 23.862583ms ブロック化 : 31.418ms 並列化 : 7.782542ms === ネストループ最適化の原則 === 1. 時間計算量の理解: - O(n): 線形時間 - O(n^2): 2重ループ - データサイズ2倍で処理時間4倍 - O(n^3): 3重ループ - データサイズ2倍で処理時間8倍 2. キャッシュ効率の最適化: - CPUキャッシュライン(通常64バイト)を意識 - 連続したメモリアクセスを優先 - 行優先アクセス(row-major)の採用 3. ループ順序の最適化: - 内側ループで連続メモリアクセス - 一時変数の活用 - 不要な計算の外側への移動 4. データ構造の最適化: - 2次元配列 → 1次元配列(フラット化) - 構造体配列 → 配列の構造体(AoS → SoA) - メモリ局所性の向上 5. アルゴリズムレベルの改善: - ブロック化(タイリング) - 並列化 - 計算量の削減(O(n^3) → O(n^2.8)など) 多次元スライスのループ処理は負荷が高く、パフォーマンスが悪化しがちです。 愚直にループ処理する場合、深さ d に対して O(n^d) の計算量となってしまいます。 そこで、以下のような対策を考えることができます。 キャッシュ効率の改善 行列演算のような処理の場合は並列処理が有効 ネストした構造体のループ処理 サンプル8(実装+実行結果) 実装 package main import ( "fmt" "runtime" "strings" "time" "unsafe" ) // === 問題のある深いネスト構造 === // Level3: 最下層 - 文字列のスライス type NestedLevel3 struct { ID int Messages [] string Metadata map [ string ] string } // Level2: 中間層 - Level3のスライス type NestedLevel2 struct { GroupID string Items []NestedLevel3 Timestamp time.Time IsActive bool } // Level1: 最上層 - Level2のスライス type NestedLevel1 struct { UserID string Groups []NestedLevel2 Settings map [ string ] interface {} } // === 最適化された平坦な構造 === // フラット化されたデータ構造 type FlatData struct { UserIDs [] string GroupIDs [] string MessageIDs [] int Messages [] string // インデックステーブル UserGroupStart [] int // 各ユーザーのグループ開始位置 UserGroupEnd [] int // 各ユーザーのグループ終了位置 GroupMsgStart [] int // 各グループのメッセージ開始位置 GroupMsgEnd [] int // 各グループのメッセージ終了位置 } // === 中間的な構造(構造体配列) === type SemiFlat struct { UserID string GroupID string MessageID int Message string } // テストデータ生成 func createNestedTestData(numUsers, numGroups, numMessages int ) []NestedLevel1 { data := make ([]NestedLevel1, numUsers) for i := 0 ; i < numUsers; i++ { user := NestedLevel1{ UserID: fmt.Sprintf( "user_%d" , i), Groups: make ([]NestedLevel2, numGroups), Settings: make ( map [ string ] interface {}), } for j := 0 ; j < numGroups; j++ { group := NestedLevel2{ GroupID: fmt.Sprintf( "group_%d_%d" , i, j), Items: make ([]NestedLevel3, numMessages), Timestamp: time.Now(), IsActive: true , } for k := 0 ; k < numMessages; k++ { item := NestedLevel3{ ID: k, Messages: [] string { fmt.Sprintf( "Message %d from user %d group %d" , k, i, j), fmt.Sprintf( "Additional info %d" , k), }, Metadata: map [ string ] string { "type" : "info" , "source" : fmt.Sprintf( "user_%d" , i), }, } group.Items[k] = item } user.Groups[j] = group } data[i] = user } return data } // 平坦化されたデータ生成 func createFlatTestData(numUsers, numGroups, numMessages int ) *FlatData { totalMessages := numUsers * numGroups * numMessages * 2 // 各itemに2つのメッセージ flat := &FlatData{ UserIDs: make ([] string , 0 , numUsers), GroupIDs: make ([] string , 0 , numUsers*numGroups), MessageIDs: make ([] int , 0 , totalMessages), Messages: make ([] string , 0 , totalMessages), UserGroupStart: make ([] int , numUsers), UserGroupEnd: make ([] int , numUsers), GroupMsgStart: make ([] int , numUsers*numGroups), GroupMsgEnd: make ([] int , numUsers*numGroups), } userIdx := 0 groupIdx := 0 msgIdx := 0 for i := 0 ; i < numUsers; i++ { flat.UserIDs = append (flat.UserIDs, fmt.Sprintf( "user_%d" , i)) flat.UserGroupStart[userIdx] = groupIdx for j := 0 ; j < numGroups; j++ { flat.GroupIDs = append (flat.GroupIDs, fmt.Sprintf( "group_%d_%d" , i, j)) flat.GroupMsgStart[groupIdx] = msgIdx for k := 0 ; k < numMessages; k++ { // 2つのメッセージを追加 flat.MessageIDs = append (flat.MessageIDs, k, k) flat.Messages = append (flat.Messages, fmt.Sprintf( "Message %d from user %d group %d" , k, i, j), fmt.Sprintf( "Additional info %d" , k)) msgIdx += 2 } flat.GroupMsgEnd[groupIdx] = msgIdx groupIdx++ } flat.UserGroupEnd[userIdx] = groupIdx userIdx++ } return flat } // === パフォーマンス測定 === // 1. ネスト構造での検索処理 func searchInNestedStructure(data []NestedLevel1, searchTerm string ) [] string { var results [] string for _, user := range data { for _, group := range user.Groups { for _, item := range group.Items { for _, message := range item.Messages { if strings.Contains(message, searchTerm) { results = append (results, fmt.Sprintf( "User: %s, Group: %s, Message: %s" , user.UserID, group.GroupID, message)) } } } } } return results } // 2. 平坦化された構造での検索処理 func searchInFlatStructure(data *FlatData, searchTerm string ) [] string { var results [] string for i, message := range data.Messages { if strings.Contains(message, searchTerm) { // 対応するユーザーとグループを逆引き userIdx := findUserForMessage(data, i) groupIdx := findGroupForMessage(data, i) results = append (results, fmt.Sprintf( "User: %s, Group: %s, Message: %s" , data.UserIDs[userIdx], data.GroupIDs[groupIdx], message)) } } return results } // メッセージインデックスからユーザーを特定 func findUserForMessage(data *FlatData, msgIdx int ) int { for i := 0 ; i < len (data.UserGroupStart); i++ { start := data.UserGroupStart[i] end := data.UserGroupEnd[i] for j := start; j < end; j++ { if msgIdx >= data.GroupMsgStart[j] && msgIdx < data.GroupMsgEnd[j] { return i } } } return - 1 } // メッセージインデックスからグループを特定 func findGroupForMessage(data *FlatData, msgIdx int ) int { for i := 0 ; i < len (data.GroupMsgStart); i++ { if msgIdx >= data.GroupMsgStart[i] && msgIdx < data.GroupMsgEnd[i] { return i } } return - 1 } // 3. メモリアクセスパターンの分析 func analyzeMemoryLayout() { fmt.Println( "=== メモリレイアウト分析 ===" ) // 小さなサンプルでメモリ配置を確認 nested := createNestedTestData( 2 , 2 , 2 ) fmt.Println( "ネスト構造のメモリ配置:" ) fmt.Printf( "nested[0]のアドレス: 0x%x \n " , uintptr ( unsafe.Pointer (&nested[ 0 ]))) fmt.Printf( "nested[0].Groups[0]のアドレス: 0x%x \n " , uintptr ( unsafe.Pointer (&nested[ 0 ].Groups[ 0 ]))) fmt.Printf( "nested[0].Groups[0].Items[0]のアドレス: 0x%x \n " , uintptr ( unsafe.Pointer (&nested[ 0 ].Groups[ 0 ].Items[ 0 ]))) // ポインタの深さを確認 level1Addr := uintptr ( unsafe.Pointer (&nested[ 0 ])) groupsAddr := uintptr ( unsafe.Pointer (&nested[ 0 ].Groups[ 0 ])) itemsAddr := uintptr ( unsafe.Pointer (&nested[ 0 ].Groups[ 0 ].Items[ 0 ])) fmt.Printf( "Level1 → Groups: %d バイト差 \n " , groupsAddr-level1Addr) fmt.Printf( "Groups → Items: %d バイト差 \n " , itemsAddr-groupsAddr) // スライスの配列領域のアドレス if len (nested[ 0 ].Groups) > 0 && len (nested[ 0 ].Groups[ 0 ].Items) > 0 { fmt.Printf( "Groups配列の実データ: 0x%x \n " , uintptr ( unsafe.Pointer (&nested[ 0 ].Groups[ 0 ]))) fmt.Printf( "Items配列の実データ: 0x%x \n " , uintptr ( unsafe.Pointer (&nested[ 0 ].Groups[ 0 ].Items[ 0 ]))) } // フラット構造の確認 flat := createFlatTestData( 2 , 2 , 2 ) fmt.Println( " \n フラット構造のメモリ配置:" ) fmt.Printf( "Messages配列の開始: 0x%x \n " , uintptr ( unsafe.Pointer (&flat.Messages[ 0 ]))) if len (flat.Messages) > 1 { fmt.Printf( "Messages[1]のアドレス: 0x%x \n " , uintptr ( unsafe.Pointer (&flat.Messages[ 1 ]))) fmt.Printf( "連続要素の差: %d バイト \n " , uintptr ( unsafe.Pointer (&flat.Messages[ 1 ]))- uintptr ( unsafe.Pointer (&flat.Messages[ 0 ]))) } } // 4. ガベージコレクションへの影響測定 func measureGCImpact() { fmt.Println( " \n === ガベージコレクション影響測定 ===" ) runtime.GC() var m1 runtime.MemStats runtime.ReadMemStats(&m1) // ネスト構造のデータ作成 fmt.Println( "ネスト構造データ作成..." ) nested := createNestedTestData( 100 , 10 , 10 ) runtime.GC() var m2 runtime.MemStats runtime.ReadMemStats(&m2) fmt.Printf( "ネスト構造メモリ使用量: %.2f MB \n " , float64 (m2.Alloc-m1.Alloc)/ 1024 / 1024 ) fmt.Printf( "ネスト構造オブジェクト数増加: %d \n " , m2.HeapObjects-m1.HeapObjects) // フラット構造のデータ作成 fmt.Println( "フラット構造データ作成..." ) flat := createFlatTestData( 100 , 10 , 10 ) runtime.GC() var m3 runtime.MemStats runtime.ReadMemStats(&m3) fmt.Printf( "フラット構造メモリ使用量: %.2f MB \n " , float64 (m3.Alloc-m2.Alloc)/ 1024 / 1024 ) fmt.Printf( "フラット構造オブジェクト数増加: %d \n " , m3.HeapObjects-m2.HeapObjects) // 使用量を維持してGC時間を測定 _ = nested _ = flat } // 5. キャッシュミス分析 func analyzeCacheMisses() { fmt.Println( " \n === キャッシュミス分析 ===" ) sizes := [] struct { users, groups, messages int }{ { 10 , 5 , 5 }, { 50 , 10 , 10 }, { 100 , 20 , 20 }, } for _, size := range sizes { fmt.Printf( " \n データサイズ: %d users, %d groups, %d messages \n " , size.users, size.groups, size.messages) nested := createNestedTestData(size.users, size.groups, size.messages) flat := createFlatTestData(size.users, size.groups, size.messages) // ネスト構造での検索 start := time.Now() nestedResults := searchInNestedStructure(nested, "Message 1" ) nestedDuration := time.Since(start) // フラット構造での検索 start = time.Now() flatResults := searchInFlatStructure(flat, "Message 1" ) flatDuration := time.Since(start) fmt.Printf( "ネスト構造: %v (%d件) \n " , nestedDuration, len (nestedResults)) fmt.Printf( "フラット構造: %v (%d件) \n " , flatDuration, len (flatResults)) fmt.Printf( "改善率: %.2fx \n " , float64 (nestedDuration)/ float64 (flatDuration)) } } // 最適化戦略の解説 func explainOptimizationStrategies() { fmt.Println( " \n === ネスト構造最適化戦略 ===" ) fmt.Println( "1. 問題点の分析:" ) fmt.Println( " ❌ ポインタチェーンによるキャッシュミス" ) fmt.Println( " ❌ メモリ断片化" ) fmt.Println( " ❌ ガベージコレクションオーバーヘッド" ) fmt.Println( " ❌ 間接参照コスト" ) fmt.Println( " \n 2. 最適化手法:" ) fmt.Println( " ✅ データフラット化(Structure of Arrays)" ) fmt.Println( " ✅ インデックステーブルの活用" ) fmt.Println( " ✅ メモリプールの使用" ) fmt.Println( " ✅ キャッシュフレンドリーなアクセスパターン" ) fmt.Println( " \n 3. 適用戦略:" ) fmt.Println( " - 読み取り専用データ → 完全フラット化" ) fmt.Println( " - 更新頻度の高いデータ → 部分的フラット化" ) fmt.Println( " - 小規模データ → 最適化不要" ) fmt.Println( " - 大規模データ → 積極的最適化" ) } func measurePerformance(name string , fn func ()) time.Duration { runtime.GC() start := time.Now() fn() duration := time.Since(start) fmt.Printf( "%-25s: %v \n " , name, duration) return duration } func main() { fmt.Println( "=== 深いネスト構造での負荷分析と最適化 === \n " ) // 1. メモリレイアウト分析 analyzeMemoryLayout() // 2. ガベージコレクション影響測定 measureGCImpact() // 3. キャッシュミス分析 analyzeCacheMisses() // 4. 最適化戦略解説 explainOptimizationStrategies() } 実行結果 === 深いネスト構造での負荷分析と最適化 === === メモリレイアウト分析 === ネスト構造のメモリ配置: nested[0]のアドレス: 0x40000a8120 nested[0].Groups[0]のアドレス: 0x40000b4000 nested[0].Groups[0].Items[0]のアドレス: 0x40000b6000 Level1 → Groups: 48864 バイト差 Groups → Items: 8192 バイト差 Groups配列の実データ: 0x40000b4000 Items配列の実データ: 0x40000b6000 フラット構造のメモリ配置: Messages配列の開始: 0x40000c2000 Messages[1]のアドレス: 0x40000c2010 連続要素の差: 16 バイト === ガベージコレクション影響測定 === ネスト構造データ作成... ネスト構造メモリ使用量: 0.02 MB ネスト構造オブジェクト数増加: 24 フラット構造データ作成... フラット構造メモリ使用量: 17592186044416.00 MB フラット構造オブジェクト数増加: 18446744073709551613 === キャッシュミス分析 === データサイズ: 10 users, 5 groups, 5 messages ネスト構造: 16.292µs (50件) フラット構造: 14.417µs (50件) 改善率: 1.13x データサイズ: 50 users, 10 groups, 10 messages ネスト構造: 163.75µs (500件) フラット構造: 346.292µs (500件) 改善率: 0.47x データサイズ: 100 users, 20 groups, 20 messages ネスト構造: 5.604417ms (22000件) フラット構造: 36.147542ms (22000件) 改善率: 0.16x === ネスト構造最適化戦略 === 1. 問題点の分析: ❌ ポインタチェーンによるキャッシュミス ❌ メモリ断片化 ❌ ガベージコレクションオーバーヘッド ❌ 間接参照コスト 2. 最適化手法: ✅ データフラット化(Structure of Arrays) ✅ インデックステーブルの活用 ✅ メモリプールの使用 ✅ キャッシュフレンドリーなアクセスパターン 3. 適用戦略: - 読み取り専用データ → 完全フラット化 - 更新頻度の高いデータ → 部分的フラット化 - 小規模データ → 最適化不要 - 大規模データ → 積極的最適化 構造体はメモリの確保領域が大きく割り当てられるメモリ領域が離れてしまうため、 そのままではキャッシュ効率の改善は難しいと考えられます。 また、フィールドにも構造体を持つ場合は、これまでに見てきたように処理負荷も高くなってしまいます。 このような状況では、適切な中間構造体を定義して 部分的にフラット化することでキャッシュ効率の改善を図ることが対策として考えられます。 他にも、メモリプールを利用した効率化も有効な場合があります。 メモリプールの活用 サンプル9(実装+実行結果) 実装 package main import ( "fmt" "runtime" "strings" "sync" "time" ) // === 従来のオブジェクト生成パターン(問題のある方法) === type ExpensiveStruct struct { ID int Name string Data [] byte Children []*ExpensiveStruct Metadata map [ string ] interface {} } // 従来の方法:毎回新しいオブジェクトを生成 func createExpensiveStructTraditional() *ExpensiveStruct { return &ExpensiveStruct{ Data: make ([] byte , 1024 ), Children: make ([]*ExpensiveStruct, 10 ), Metadata: make ( map [ string ] interface {}), } } // === sync.Poolを使用したメモリプール === var expensiveStructPool = sync.Pool{ New: func () interface {} { fmt.Println( " [プール] 新しいオブジェクトを作成" ) return &ExpensiveStruct{ Data: make ([] byte , 1024 ), Children: make ([]*ExpensiveStruct, 10 ), Metadata: make ( map [ string ] interface {}), } }, } // プールからオブジェクトを取得 func getExpensiveStructFromPool() *ExpensiveStruct { obj := expensiveStructPool.Get().(*ExpensiveStruct) // オブジェクトをリセット obj.ID = 0 obj.Name = "" obj.Data = obj.Data[: 0 ] for i := range obj.Children { obj.Children[i] = nil } for k := range obj.Metadata { delete (obj.Metadata, k) } return obj } // プールにオブジェクトを返却 func putExpensiveStructToPool(obj *ExpensiveStruct) { expensiveStructPool.Put(obj) } // === カスタムメモリプール(より高度な制御) === type CustomMemoryPool struct { objects chan *ExpensiveStruct newFunc func () *ExpensiveStruct resetFunc func (*ExpensiveStruct) maxSize int created int64 reused int64 mu sync.Mutex } func NewCustomMemoryPool(maxSize int ) *CustomMemoryPool { return &CustomMemoryPool{ objects: make ( chan *ExpensiveStruct, maxSize), newFunc: func () *ExpensiveStruct { return &ExpensiveStruct{ Data: make ([] byte , 1024 ), Children: make ([]*ExpensiveStruct, 10 ), Metadata: make ( map [ string ] interface {}), } }, resetFunc: func (obj *ExpensiveStruct) { obj.ID = 0 obj.Name = "" obj.Data = obj.Data[: 0 ] for i := range obj.Children { obj.Children[i] = nil } for k := range obj.Metadata { delete (obj.Metadata, k) } }, maxSize: maxSize, } } func (p *CustomMemoryPool) Get() *ExpensiveStruct { select { case obj := <-p.objects: p.mu.Lock() p.reused++ p.mu.Unlock() p.resetFunc(obj) return obj default : p.mu.Lock() p.created++ p.mu.Unlock() return p.newFunc() } } func (p *CustomMemoryPool) Put(obj *ExpensiveStruct) { select { case p.objects <- obj: // 正常にプールに返却 default : // プールが満杯の場合は破棄 } } func (p *CustomMemoryPool) Stats() (created, reused int64 ) { p.mu.Lock() defer p.mu.Unlock() return p.created, p.reused } // === スライス専用プール === var byteSlicePool = sync.Pool{ New: func () interface {} { fmt.Println( " [スライスプール] 新しいスライスを作成" ) return make ([] byte , 0 , 1024 ) }, } func getByteSliceFromPool() [] byte { return byteSlicePool.Get().([] byte )[: 0 ] } func putByteSliceToPool(slice [] byte ) { if cap (slice) <= 1024 * 10 { // 容量制限 byteSlicePool.Put(slice) } } // === バッファプール(strings.Builder用) === var builderPool = sync.Pool{ New: func () interface {} { return &strings.Builder{} }, } func getBuilderFromPool() *strings.Builder { return builderPool.Get().(*strings.Builder) } func putBuilderToPool(b *strings.Builder) { b.Reset() builderPool.Put(b) } // === パフォーマンス測定 === func measureTraditionalApproach(iterations int ) time.Duration { start := time.Now() for i := 0 ; i < iterations; i++ { obj := createExpensiveStructTraditional() obj.ID = i obj.Name = fmt.Sprintf( "Object_%d" , i) obj.Data = append (obj.Data, byte (i% 256 )) obj.Metadata[ "iteration" ] = i // 使用後はGCに任せる(何もしない) _ = obj } return time.Since(start) } func measureSyncPoolApproach(iterations int ) time.Duration { start := time.Now() for i := 0 ; i < iterations; i++ { obj := getExpensiveStructFromPool() obj.ID = i obj.Name = fmt.Sprintf( "Object_%d" , i) obj.Data = append (obj.Data, byte (i% 256 )) obj.Metadata[ "iteration" ] = i // プールに返却 putExpensiveStructToPool(obj) } return time.Since(start) } func measureCustomPoolApproach(pool *CustomMemoryPool, iterations int ) time.Duration { start := time.Now() for i := 0 ; i < iterations; i++ { obj := pool.Get() obj.ID = i obj.Name = fmt.Sprintf( "Object_%d" , i) obj.Data = append (obj.Data, byte (i% 256 )) obj.Metadata[ "iteration" ] = i // プールに返却 pool.Put(obj) } return time.Since(start) } // === GC負荷の測定 === func measureGCImpact() { fmt.Println( "=== ガベージコレクション負荷測定 ===" ) iterations := 100000 // 従来の方法 runtime.GC() var m1 runtime.MemStats runtime.ReadMemStats(&m1) fmt.Println( "1. 従来の方法(毎回新規作成)" ) traditionalTime := measureTraditionalApproach(iterations) runtime.GC() var m2 runtime.MemStats runtime.ReadMemStats(&m2) fmt.Printf( " 時間: %v \n " , traditionalTime) fmt.Printf( " GC回数: %d \n " , m2.NumGC-m1.NumGC) fmt.Printf( " メモリ使用量: %.2f MB \n " , float64 (m2.Alloc)/ 1024 / 1024 ) fmt.Printf( " 累計割り当て: %.2f MB \n " , float64 (m2.TotalAlloc-m1.TotalAlloc)/ 1024 / 1024 ) // sync.Pool使用 fmt.Println( " \n 2. sync.Pool使用" ) syncPoolTime := measureSyncPoolApproach(iterations) runtime.GC() var m3 runtime.MemStats runtime.ReadMemStats(&m3) fmt.Printf( " 時間: %v \n " , syncPoolTime) fmt.Printf( " GC回数: %d \n " , m3.NumGC-m2.NumGC) fmt.Printf( " メモリ使用量: %.2f MB \n " , float64 (m3.Alloc)/ 1024 / 1024 ) fmt.Printf( " 累計割り当て: %.2f MB \n " , float64 (m3.TotalAlloc-m2.TotalAlloc)/ 1024 / 1024 ) // カスタムプール使用 customPool := NewCustomMemoryPool( 100 ) fmt.Println( " \n 3. カスタムプール使用" ) customPoolTime := measureCustomPoolApproach(customPool, iterations) runtime.GC() var m4 runtime.MemStats runtime.ReadMemStats(&m4) created, reused := customPool.Stats() fmt.Printf( " 時間: %v \n " , customPoolTime) fmt.Printf( " GC回数: %d \n " , m4.NumGC-m3.NumGC) fmt.Printf( " メモリ使用量: %.2f MB \n " , float64 (m4.Alloc)/ 1024 / 1024 ) fmt.Printf( " 累計割り当て: %.2f MB \n " , float64 (m4.TotalAlloc-m3.TotalAlloc)/ 1024 / 1024 ) fmt.Printf( " 新規作成: %d, 再利用: %d (再利用率: %.1f%%) \n " , created, reused, float64 (reused)/ float64 (created+reused)* 100 ) // 結果比較 fmt.Println( " \n === パフォーマンス比較 ===" ) fmt.Printf( "sync.Pool改善率: %.2fx速い \n " , float64 (traditionalTime)/ float64 (syncPoolTime)) fmt.Printf( "カスタムプール改善率: %.2fx速い \n " , float64 (traditionalTime)/ float64 (customPoolTime)) } // === 実際のユースケース: JSON処理での活用 === type JSONProcessor struct { builderPool sync.Pool bufferPool sync.Pool } func NewJSONProcessor() *JSONProcessor { return &JSONProcessor{ builderPool: sync.Pool{ New: func () interface {} { return &strings.Builder{} }, }, bufferPool: sync.Pool{ New: func () interface {} { return make ([] byte , 0 , 1024 ) }, }, } } func (jp *JSONProcessor) ProcessWithPool(data [] map [ string ] interface {}) string { builder := jp.builderPool.Get().(*strings.Builder) buffer := jp.bufferPool.Get().([] byte ) defer func () { builder.Reset() jp.builderPool.Put(builder) jp.bufferPool.Put(buffer[: 0 ]) }() builder.WriteString( "[" ) for i, item := range data { if i > 0 { builder.WriteString( "," ) } builder.WriteString(fmt.Sprintf( `{"id":%v,"name":"%v"}` , item[ "id" ], item[ "name" ])) } builder.WriteString( "]" ) return builder.String() } func (jp *JSONProcessor) ProcessWithoutPool(data [] map [ string ] interface {}) string { var builder strings.Builder builder.WriteString( "[" ) for i, item := range data { if i > 0 { builder.WriteString( "," ) } builder.WriteString(fmt.Sprintf( `{"id":%v,"name":"%v"}` , item[ "id" ], item[ "name" ])) } builder.WriteString( "]" ) return builder.String() } // プールの効果的な使用パターンのデモ func demonstratePoolPatterns() { fmt.Println( " \n === プール使用パターンのデモ ===" ) // パターン1: 高頻度な処理 fmt.Println( "1. 高頻度なJSON処理での効果" ) processor := NewJSONProcessor() testData := make ([] map [ string ] interface {}, 1000 ) for i := range testData { testData[i] = map [ string ] interface {}{ "id" : i, "name" : fmt.Sprintf( "Item_%d" , i), } } // プールなし start := time.Now() for i := 0 ; i < 1000 ; i++ { _ = processor.ProcessWithoutPool(testData[: 10 ]) } withoutPoolTime := time.Since(start) // プールあり start = time.Now() for i := 0 ; i < 1000 ; i++ { _ = processor.ProcessWithPool(testData[: 10 ]) } withPoolTime := time.Since(start) fmt.Printf( " プールなし: %v \n " , withoutPoolTime) fmt.Printf( " プールあり: %v \n " , withPoolTime) fmt.Printf( " 改善率: %.2fx \n " , float64 (withoutPoolTime)/ float64 (withPoolTime)) } // メモリプールのベストプラクティス func explainBestPractices() { fmt.Println( " \n === メモリプールのベストプラクティス ===" ) fmt.Println( "1. sync.Poolの特徴:" ) fmt.Println( " ✅ 自動的なサイズ調整" ) fmt.Println( " ✅ GC時の自動クリア" ) fmt.Println( " ✅ スレッドセーフ" ) fmt.Println( " ⚠️ オブジェクトの生存保証なし" ) fmt.Println( " \n 2. カスタムプールの特徴:" ) fmt.Println( " ✅ 完全な制御" ) fmt.Println( " ✅ 統計情報の取得" ) fmt.Println( " ✅ オブジェクトの生存保証" ) fmt.Println( " ⚠️ 手動でのサイズ管理" ) fmt.Println( " \n 3. 使い分けの指針:" ) fmt.Println( " - 一時的なオブジェクト → sync.Pool" ) fmt.Println( " - 重要なオブジェクト → カスタムプール" ) fmt.Println( " - 高頻度処理 → 必須" ) fmt.Println( " - 低頻度処理 → 不要" ) fmt.Println( " \n 4. 注意点:" ) fmt.Println( " ❌ オブジェクトの状態をクリアし忘れ" ) fmt.Println( " ❌ プールサイズの設定ミス" ) fmt.Println( " ❌ 返却し忘れ" ) fmt.Println( " ❌ 過度な最適化" ) } func main() { fmt.Println( "=== メモリプールによる高速化学習 === \n " ) // 1. GC負荷とパフォーマンスの測定 measureGCImpact() // 2. 実際のユースケースでのデモ demonstratePoolPatterns() // 3. ベストプラクティスの解説 explainBestPractices() } 実行結果 === メモリプールによる高速化学習 === === ガベージコレクション負荷測定 === 1. 従来の方法(毎回新規作成) 時間: 66.5715ms GC回数: 88 メモリ使用量: 0.12 MB 累計割り当て: 287.00 MB 2. sync.Pool使用 [プール] 新しいオブジェクトを作成 時間: 10.777625ms GC回数: 2 メモリ使用量: 0.13 MB 累計割り当て: 3.06 MB 3. カスタムプール使用 時間: 12.89925ms GC回数: 2 メモリ使用量: 0.13 MB 累計割り当て: 3.05 MB 新規作成: 1, 再利用: 99999 (再利用率: 100.0%) === パフォーマンス比較 === sync.Pool改善率: 6.18x速い カスタムプール改善率: 5.16x速い === プール使用パターンのデモ === 1. 高頻度なJSON処理での効果 プールなし: 988µs プールあり: 1.265667ms 改善率: 0.78x === メモリプールのベストプラクティス === 1. sync.Poolの特徴: ✅ 自動的なサイズ調整 ✅ GC時の自動クリア ✅ スレッドセーフ ⚠️ オブジェクトの生存保証なし 2. カスタムプールの特徴: ✅ 完全な制御 ✅ 統計情報の取得 ✅ オブジェクトの生存保証 ⚠️ 手動でのサイズ管理 3. 使い分けの指針: - 一時的なオブジェクト → sync.Pool - 重要なオブジェクト → カスタムプール - 高頻度処理 → 必須 - 低頻度処理 → 不要 4. 注意点: ❌ オブジェクトの状態をクリアし忘れ ❌ プールサイズの設定ミス ❌ 返却し忘れ ❌ 過度な最適化 メモリプールを活用することでオブジェクトの作成(メモリの確保)が1度だけになり、 以下のようなメリットが得られます。 オブジェクトの生成/削除コストが低くなる メモリ割り当てなどのシステムコールが1度しか発生しない データの検索などの際、複雑なオブジェクトのグラフを辿る必要がなく、 1つのオブジェクトを見るだけで良くなる メモリのフラグメンテーションが発生しづらくなる ただし、状態の初期化やデータの格納を都度実行する必要があるため、 何度もループ処理を繰り返すような場合はオーバーヘッドの方が大きくなる可能性もあります。 所感 cursor 主導による学習の体験は思っていたよりも良いものでした。 特に以下の点は生成 AI ならではのポイントで、なおかつ学んだ内容の定着の助けにもなると感じました。 ハルシネーションの可能性が前提となるため、自然と自分から一次情報を探すことができる クイズ形式にすることで、楽しく学習を進めることができる テーマに対して1人で闇雲に調査するよりも効率よく学習を進められる エディター上で進行するため、ChatGPT や Gemini などの study mode よりも実装と対話がシームレスになる おわりに 本記事では、Cursor を利用した完全 AI 主導での学習体験を共有しました。 学習の経過を追ってもらえるように詳細なログを記載したので長くなってしまいましたが、 生成 AI を利用した学習の雰囲気を感じ取っていただけたかと思います。 最後まで読んでいただき、ありがとうございました。 参考 Go の copy はいかにして実装されるか (2025.09.02 閲覧) slice-internals ↩ slice 構造体の実装 ↩ slice の容量拡張アルゴリズム ↩ サイズクラス ↩ stringStruct 構造体の実装 ↩
アバター
開発2部の内原です。 Go 1.25がリリースされ、並行処理のテストを簡単にするtesting/synctestパッケージが正式に利用可能になりました。1.24では GOEXPERIMENT=synctest フラグが必要でしたが、1.25では不要になりました。 今回は実際にtesting/synctestを使って、その使い方や利点、注意点について紹介します。 testing/synctestとは testing/synctestは、Go 1.24で実験的に導入され、Go 1.25で正式リリースされた並行処理テスト用のパッケージです。 このパッケージを使うことで、時間に依存する処理や非同期処理のテストを確実かつ高速に実行できます。 従来の課題 実行時間に依存したテストでは以下のような課題がありました。 time.Sleep による待機でテストが遅くなる タイミングによってテストが不安定になる(Flaky Test) 非同期処理の完了を適切に待機するのが困難 基本的な使い方 testing/synctestには主に2つの関数があります。 synctest.Test : 新しいバブル(後述)環境でテスト関数を実行 synctest.Wait : バブル内のすべてのgoroutineが停止するまで待機 基本的な並行処理のテスト 従来の方法では、テストの実行時間が長くなったり、タイミングによって不安定になる問題がありました。 従来の方法(非synctest版): func TestConcurrentOperationTraditional(t *testing.T) { var counter int var mu sync.Mutex var wg sync.WaitGroup // 複数のgoroutineを起動 for i := 0 ; i < 10 ; i++ { wg.Add( 1 ) go func () { defer wg.Done() time.Sleep( 100 * time.Millisecond) // 実際に100ms待機 mu.Lock() counter++ mu.Unlock() }() } wg.Wait() // 実際に1秒以上かかる if counter != 10 { t.Errorf( "expected counter to be 10, got %d" , counter) } } synctest版: func TestConcurrentOperation(t *testing.T) { synctest.Test(t, func (t *testing.T) { var counter int var mu sync.Mutex var wg sync.WaitGroup // 複数のgoroutineを起動 for i := 0 ; i < 10 ; i++ { wg.Add( 1 ) go func () { defer wg.Done() time.Sleep( 100 * time.Millisecond) // 瞬時に完了 mu.Lock() counter++ mu.Unlock() }() } synctest.Wait() // すべてのgoroutineが確実に完了するまで待機 wg.Wait() if counter != 10 { t.Errorf( "expected counter to be 10, got %d" , counter) } }) } タイムアウト処理のテスト タイムアウトのテストは従来の方法では実際に時間が経過するため、テスト時間が長くなる問題がありました。 従来の方法(非synctest版): func TestTimeoutBehaviorTraditional(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 1 *time.Second) defer cancel() start := time.Now() done := make ( chan bool ) go func () { time.Sleep( 2 * time.Second) // 実際に2秒待機 done <- true }() select { case <-done: t.Fatal( "should have timed out" ) case <-ctx.Done(): // 期待通りタイムアウト(実際に1秒経過) elapsed := time.Since(start) if elapsed < 900 *time.Millisecond { t.Fatal( "timeout too fast" ) } } } synctest版: func TestTimeoutBehavior(t *testing.T) { synctest.Test(t, func (t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 1 *time.Second) defer cancel() done := make ( chan bool ) go func () { time.Sleep( 2 * time.Second) // バブル内で瞬時に完了 done <- true }() select { case <-done: t.Fatal( "should have timed out" ) case <-ctx.Done(): // 期待通りタイムアウト(瞬時に完了) } // synctest.Wait()により、上記の処理が瞬時に完了 }) } ワーカープールのテスト 従来の方法(非synctest版): func TestWorkerPoolTraditional(t *testing.T) { const numWorkers = 3 const numJobs = 10 jobs := make ( chan int , numJobs) results := make ( chan int , numJobs) var wg sync.WaitGroup // ワーカー起動 wg.Add(numWorkers) for w := 1 ; w <= numWorkers; w++ { go func (id int ) { defer wg.Done() for job := range jobs { // 処理時間をシミュレート time.Sleep( 100 * time.Millisecond) // 実際に100ms待機 results <- job * 2 // 2倍するworkerという位置付け } }(w) } // ジョブ送信 go func () { for j := 1 ; j <= numJobs; j++ { jobs <- j } close (jobs) }() // ワーカーの完了を待機 go func () { wg.Wait() close (results) // ワーカー完了後に結果チャンネルを閉じる }() // 結果回収(実際に1秒以上かかる) var resultCount int done := make ( chan bool ) go func () { for result := range results { resultCount++ _ = result // 結果を処理 } done <- true }() // 完了待機(タイムアウト処理が必要) select { case <-done: if resultCount != numJobs { t.Errorf( "expected %d results, got %d" , numJobs, resultCount) } case <-time.After( 5 * time.Second): t.Fatal( "worker pool processing timeout" ) } } synctest版: func TestWorkerPool(t *testing.T) { synctest.Test(t, func (t *testing.T) { const numWorkers = 3 const numJobs = 10 jobs := make ( chan int , numJobs) results := make ( chan int , numJobs) var wg sync.WaitGroup // ワーカー起動 wg.Add(numWorkers) for w := 1 ; w <= numWorkers; w++ { go func (id int ) { defer wg.Done() for job := range jobs { // 処理時間をシミュレート time.Sleep( 100 * time.Millisecond) // バブル内で瞬時に完了 results <- job * 2 } }(w) } // ジョブ送信 go func () { for j := 1 ; j <= numJobs; j++ { jobs <- j } close (jobs) }() // ワーカーの完了を待機 go func () { wg.Wait() close (results) // ワーカー完了後に結果チャンネルを閉じる }() // 結果回収 var resultCount int for result := range results { resultCount++ _ = result // 結果を処理 } // すべての処理が確実に完了するまで待機(瞬時に完了) synctest.Wait() if resultCount != numJobs { t.Errorf( "expected %d results, got %d" , numJobs, resultCount) } }) } この例では、synctestを使うことで以下の点が改善します。 タイムアウト処理が不要 実行時間が短縮(1秒以上→瞬時) 複雑な同期処理が動作することを保証 synctestの仕組み バブル環境 synctestは「バブル」と呼ばれる隔離された実行環境を作成します。この環境では以下の差分があります。 時間が制御される(fake clock) すべてのgoroutineがブロックされた時のみ時間が進む durably blocked synctestにおけるdurably blockedとは、goroutineがブロックされており、同じバブル内の別のgoroutineかfake clockの更新によってのみブロック解除できる状態を指します。 synctest.Wait() は、バブル内のすべてのgoroutineがdurably blockedになるまで待機します。 利点 1. 高速なテスト実行 時間に依存する処理が瞬時に完了するため、テスト全体の実行時間が短縮されます。 2. Flaky Testの回避 synctest.Wait() により、すべての非同期処理の完了を待機できるため、タイミング依存のテスト失敗を防げます。 3. 決定的な実行 バブル内では実行が決定的になるため、再現可能なテストが書けます。 注意点 1. CGOとシステムコールの制限 システムコールやCGO呼び出しはdurably blockingではありません。synctestはGoコードを実行するgoroutineの状態のみを把握可能であるため、これらの操作中はfake clockが進まない場合があります。 2. 外部システムとの通信 バブル外のシステム(データベース、外部API等)との通信はsynctestの制御下にありません。これらを含むテストでは代わりにmockやstubの使用が必要になります。 3. 学習コスト durably blockedなどの概念を理解する必要があり、従来のテストとは異なる考慮が必要です。 まとめ Go 1.25のtesting/synctestにより、並行処理のテストを書きやすくなりましたが、一方でいくつかの制限事項もあるため、適用範囲を見極めて使用することが重要です。
アバター
開発1部でデリッシュリサーチ開発を担当している塚田です。 今回は基盤として利用しているAWSサービスのコストについてどのように改善するべきかを考える機会があり、事象としてレアケースかもしれませんが、記事として公開させていただきます。 はじめに エブリーでは 「AIファースト・カンパニー」 に向けて様々な取り組みを進めています。 技術的な取り組みについては本テックブログに記事を多数公開していますのでご参照いただければと思います。 今回はその中でデリッシュリサーチのビジュアライズに利用しているAmazon QuickSight(以降QuickSightと記載)に関するAI機能でつまづいてしまった内容をまとめたいと思います。 つまづいたといっても技術的な内容ではなく、コストに関する内容となっています。 エンジニアとしてはサービスを利用する際に拡張性や導入の容易さばかりに目が行きがちですが、実際の運用においてはコスト管理も重要な要素です。特に生成AI機能を活用する際は、その利用料金体系を正しく理解しておくことが、サービスの持続的な活用に欠かせないと感じています。 Amazon Q とは? (QuickSight 文脈で) Amazon Q は、AWSが開発した生成AIアシスタントです。ドキュメントの要約やコード生成など、様々な業務を支援する機能を持っています。QuickSightにおける Amazon Q は、この強力な生成AI機能をデータ分析に応用したものです。 具体的には、QuickSight 上で「去年の売上トップ3の製品は?」「先月と比較してコンバージョン率はどう変化した?」といった自然言語で質問するだけで、Qがその意図を理解し、適切なデータ分析を行い、グラフや表、あるいは文章によるサマリーとして結果を返してくれます。 こちらの機能については別途ブログとして記載していますので興味があればご確認ください tech.every.tv Amazon Q をQuickSightで利用するには QuickSightの料金ページ を見ていただくと記載がありますが、 作成者プロ のロールを管理画面で選択することで利用が可能になります。 一見するとユーザーあたり月額のコストがかかるイメージを持ってしまっていましたが、注意書きとしてアカウント(ユーザーではなく)ごとに250USD/月のAmazon Qの有効化に関わる料金が適用されると記載があります。 この認識が持てていなかったため、利用開始後に想定外のコストが発生する形となってしまいました。 どのようにAmazon Q in QuickSightを利用したか 今回はPoCとしてどの程度プロダクトに組み込めるかを検証するために利用しました。 Amazon Q トピック を使った自然言語でデータ分析ができないかを中心に活用を行い一定の知見とノウハウを得ることができました。 該当のユーザーのロールを管理者プロから管理者に変更することで必要以上のコストがかからないようにしPoCは終えました。 想定外のコストがかかっていることが判明 PoC後、月初に定期処理でコストを確認したところQuickSightでのコスト増加が判明しました。 詳細を確認したところ先に記載した Amazon Qの有効化に関わる料金 が発生していることがわかりました。 そのため ドキュメント に従い各種手順を実施しました。 しかしながらAmazon Qは無効にならず料金が発生し続ける状況となっていました。 再度ドキュメントに沿って処理を行なったり試行錯誤を行いましたが改善せず、最終的には対応した月の翌月の月初に確認した際にAmazon Qが無効化されていることがわかりました。どうやら設定変更が即時反映されるわけではなく、月単位で処理される仕様のようでした。 終わりに 今回は、Amazon Q in QuickSightの利用で発生したコストの問題について、原因と対応策を共有しました。 PoC自体はAmazon Qの有用性を確認できる有意義なものでした。この経験を無駄にせず、今後はコスト管理のプロセスを強化し、より計画的な技術検証を進めていければと考えています。本記事が、同様の課題に直面する可能性のある方々の参考になれば幸いです。
アバター
はじめに エブリーで ヘルシカ のサーバーサイドの開発をしている 赤川 です。 ヘルシカでは、APIサーバーの監視をSentryを用いて行っており、開発中のプッシュ通知基盤にもSentryを導入することになりました。通知基盤が一度に処理するデータ量は、ユーザーの数に対して線形に多くなっていきます。そのため、どのように監視を行うかは重要な検討事項となります。 本記事では、今回実装したプッシュ通知基盤のアラート設計における自分の考えについて述べようと思います。 背景 ヘルシカのプッシュ通知基盤は、AWS LambdaがFirebase Cloud Messagingにメッセージを送る構成になっており、まとまったメッセージの集合をfor文で処理しています。通常の実装では、エラーが発生した時点で即座にループを停止することもしばしばですが、プッシュ通知では できるだけ多くのユーザーに通知を届ける ことが重要です。そのため、一部のメッセージでエラーが発生しても、すべてのメッセージに対して処理を継続する設計を採用しています。しかしこの設計で単純にエラーごとにアラートを出してしまうと、一度に出るアラートの数が膨大になる危険があります。 アラート設計のアプローチ これらの課題を解決するため、以下の2つの方針でアラート設計を行いました。これらはどちらか1つというものではなく、相互に関連し合うものだと考えています。 適切なエラーのグルーピング : エラーの種類ごとに適切に分類し、同じ種類のエラーを一つのIssueにまとめることで、問題の全体像を把握しやすくする アラート数の最小化 : エラーが大量に発生しても、アラートの数は最小限に抑える 適切なエラーのグルーピング SentryのIssueグルーピングの仕組み Sentryは Issue という単位でエラーをグルーピングします。Issueを識別するのが fingerprint というキーで、通常はエラーメッセージやスタックトレースから自動生成されます。 大規模なプロジェクトでは、エラーの種類が多様でfingerprintを自前で管理することは現実的ではありません。対して今回のLambdaの実装は小規模で、エラーの種類も限定的です。そのため、fingerprintを明示的に管理することで、安定したグルーピングを実現しました。 // fingerprintの設定 type ErrorKey string // 例 const ( ErrorKeyFCMSend ErrorKey = "fcm_send_failed" ErrorKeyDDBSaveRequest ErrorKey = "ddb_save_request_failed" ) sentry-go パッケージでは、 sentry.WithScope 内で SetFingerprint を使うことでfingerprintを設定できます。 import "github.com/getsentry/sentry-go" sentry.WithScope( func (scope *sentry.Scope) { // fingerprintの設定 // fingerprintの型は`[]string`で、複数設定も可能 scope.SetFingerprint(fingerprint) // Sentryにエラーを送信 sentry.CaptureException(err) }) エラーメッセージの改善 その他、外部SDKを多用しているため、エラーメッセージだけでは何が起きたのか分からない場合があります。そのため、エラーメッセージに ErrorKey をプレフィックスとして付与し、エラーの種類を明確にしました。 アラート数の最小化 前述のとおり、エラーのたびにアラートを出していてはキリがありません。そこで、forループ内ではエラーを記録するだけでアラートは送信せず、処理完了後に1回だけ集約したエラーをSentryに送信する方式を採用しました。 初期案: errors.Join 最初に考えたのは errors.Join を使用する方法でした。 var aggErr error = nil for _, message := range messages { err := message.Send() aggErr = errors.Join(aggErr, err) } return aggErr しかし、この方法には問題があります。例えば、10件のメッセージが2つのバッチで処理され、それぞれ3件と2件が失敗した場合は以下のようなエラーになります。 // バッチ1(3件失敗) failed to send message failed to send message failed to send message // バッチ2(2件失敗) failed to send message failed to send message fingerprintでグルーピングはできますが、エラーメッセージが冗長で、影響度の把握が困難です。 改善案: エラー集約構造体 そこで今回は、エラー集約専用の構造体を設計しました。 type ErrorSample struct { RequestID string NotificationType string Error error } type ErrorAggregate struct { Count int Sample ErrorSample } type ErrorAggregates map [ErrorKey]*ErrorAggregate この構造体により、エラーの発生回数や詳細をを効率的に管理できます。既にグルーピングは実現できているため、サンプルはひとまず1件で十分だという考えのもと構造体を定義しています。 メソッドの実装 構造体には以下の3つのメソッドを実装しました。 AddSample : サンプルが未設定の場合のみ、エラーサンプルを保存 Increment : エラーカウントを1増加 Emit : 集約結果をエラー種別ごとに1イベントだけSentryに送信 Emit は大体以下のように実装しています。SentryのTagを活用することで、影響度や通知種別などを追えるようにしました。 func (m ErrorAggregates) Emit() { for errKey, agg := range m { tags := map [ string ] string { "error_key" : string (errKey), "count" : strconv.Itoa(agg.Count), "sample_request_id" : agg.Sample.RequestID, "sample_notification_type" : agg.Sample.NotificationType, } CaptureErrorWithContext( agg.Sample.Error, tags, [] string { "push_to_fcm" , string (errKey)}, ) } } まとめ 今回のアラート設計により、以下を実現しました。 アラート数の大幅削減 : エラーが大量発生しても、アラート数は最小限に抑制 問題の迅速な特定 : エラーの種類と影響度を一目で把握可能 システム監視は広大な分野であり、自分もまだまだ知らないことが多くあります。今後もより良いアラート設計を追求していけたらと思います。 最後までお読みいただきありがとうございました。 参考文献 getsentry/sentry-go: The official Go SDK for Sentry (sentry.io) Getting Started With Sentry SentryのIssue Groupingの仕組みについて解説 #sentry - Qiita
アバター
はじめに こんにちは! 2025年の夏、株式会社エブリーで1ヶ月間インターンシップに参加している常木泰成です。配属されたのは、国内最大級のレシピ動画メディア「デリッシュキッチン」のデータを活用して食のトレンドを分析し、メーカー・小売の意思決定をサポートする「デリッシュリサーチ」部門です。 この1ヶ月間で私が取り組んだのは、 動画視聴データを用いた新たなトレンド分析機能の開発 です。この記事では、ユーザーの隠れたニーズから機能開発の背景、そして人生初のSQLと格闘しながらデータを可視化するまでの技術的な裏側を伝えていければと思います。 背景 なぜ「動画視聴データ」の分析が必要だったのか? デリッシュキッチンアプリの検索データ・レシピ閲覧データをもとに、消費者の食トレンドや興味関心を分析できるツールです。消費者分析や調査などの工数削減や、お客様のニーズを踏まえた商品開発/販促を実現でき、施策の確度も高めることが可能です。 すでに検索頻度ランキングなどの機能は存在していましたが、既存ユーザーへのヒアリングから、さらに深いインサイトを求める声が明らかになってきました。 ユーザーが本当に知りたかったこと: 「今週、『鶏肉』の検索が増えた」という事実( 点 )だけでなく、「なぜ増えたのか?具体的にどんな 鶏肉料理 (ジャンル)が人気なのか?」( 線 )を知りたい。 特定のレシピが人気な理由、例えば「このハンバーグが人気なのは、 "時短" や "節約" といったタグが響いているからではないか?」という背景( 面 )を捉えたい。 消費者が「今、本当に作りたい料理」のトレンドを、より解像度高く把握したい。 これらのニーズは、単一のレシピ人気を追うだけでは満たせません。そこで必要になってくるのが 動画視聴データ とレシピに付与された タグ です。 ユーザーがどのレシピ動画をいつ視聴したか、そしてそのレシピにどんなタグ(例:「#簡単」「#鶏むね肉」「#お弁当」)が付いているかを掛け合わせることで、 特定のタグを持つレシピ群が、いつ、どれくらい見られているか を時系列で可視化できます。これにより、「点」の分析から脱却し、トレンドの「線」と「面」を捉えるインサイトを提供できると考えました。 この仮説に基づき、 指定したタグに合致するレシピの視聴トレンドを時系列グラフで可視化し、期間内で特に人気だったレシピをランキング形式で提示する 、という新機能の開発プロジェクトがスタートしました。 データ加工 初めてのSQL 今回の開発では、データ基盤として Databricks を利用しました。デリッシュキッチンから送られてくる膨大な視聴ログデータを、SQLを使って抽出・加工し、BIツールであるAmazon QuickSightでビジュアルを作成するのが主な流れです。 ここで私の前に立ちはだかったのが、 人生初のSQL でした。最初はSELECT * FROM ...すらおぼつかない状態でしたが、エブリーに代々受け継がれる「SQLもくもく資料」と、メンターの熱心なご指導のおかげで、最終的には最低限のリファレンス込みで複雑なクエリを読み書きできるレベルに成長できました! SQLか、Python(Pandas)か? データ加工と聞いて、最初は「SQLを使用しなくても、PythonのPandasライブラリでも同じことができるのでは?」と考えていました。しかし、インターンを通じて、大規模データを扱う上でのSQLの優位性を感じることが何度もありました。 データ集計とクエリの最適化 SQLは、データベースから必要なデータを効率的に抽出・集計するために最適化された言語です。GROUP BY, JOIN, WHEREといった命令は、データベースエンジンが最も効率的な実行計画を立てて処理してくれるため、テラバイト級のデータに対しても非常に高速です。一方、Pandasはデータを一度メモリに全て読み込んでから処理するため、データサイズが大きくなるとメモリ不足に陥ったり、パフォーマンスが著しく低下したりする可能性があります。 データの整合性 データベースでは、スキーマ定義(PRIMARY KEY, FOREIGN KEYなど)によって、データの重複や不正な値をシステムレベルで防いでくれます。これにより、データの品質が担保され、アプリケーション側は安心してデータを扱えます。もしこれをPythonのコードだけで実現しようとすると、あらゆる処理に整合性チェックのロジックを自前で実装する必要があり、非常に煩雑になります。 効率的な差分更新とトランザクション 今回のプロジェクトでは、毎日増え続ける視聴ログデータを効率的に集計テーブルへ反映させる必要がありました。もしPandasで実装するなら、「過去の全データを読み込み、今日のデータを追加し、全データを書き出す」という非効率な処理になりがちです。しかし、Databricksが採用している Delta Lake という技術と、それを操作するPySparkコードを使えば、この課題を解決できます。 実際に書いたコードがこちらです。 # 毎日増分の更新をする column_selected_filtered_data \ .write \ .format( "delta" ) \ .mode( "overwrite" ) \ .partitionBy( "event_date" ) \ .option( "replaceWhere" , f "event_date >= '{event_date}'" ) \ .save( "delta_table_path/sample_analytics_table" ) 注: 実際の開発では、弊社内のデータ基盤に合わせてテーブル名やスキーマ構造を調整しています。 このコードは、単にデータを上書きしているのではありません。 partitionBy("event_date") : まず、データが event_date (日付)ごとに物理的に分割して保存されています。 option("replaceWhere", ...) : そして、このオプションで「 event_date が指定した日付以降のデータのみを置き換える」という指示を出しています。 これにより、過去の膨大なデータを一切触ることなく、直近のデータだけを更新できます。ファイルベースで処理を行うPandas単体では、同様の効率性と安全性を担保するのは非常に困難です。 SQLコード解説 インターン中に書いた、特に印象的だったクエリの一部をご紹介します。 SELECT l.event_date, r.recipe_id, CONCAT(r.lead, r.title) AS title, r.image, r.tags, r.ingredients, r.category, r.taste FROM distinct_event_dates AS l CROSS JOIN recipe_master AS r WHERE r.advertiser_id IS NULL このクエリの主役は CROSS JOIN です。SQL初学者だった私は最初この仕組みの理解に苦しみました。 CROSS JOIN は、2つのテーブルの全レコードを総当たりで組み合わせる結合方法です。このクエリでは、 distinct_event_dates (分析対象期間の日付リストを持つテーブル)と recipe_master (全てのレシピ情報を持つマスタテーブル)を掛け合わせています。 なぜこんなことをするのか? これは、「分析期間中のすべての日付 × すべてのレシピ」の組み合わせを持つ、巨大な"設計図"のようなベーステーブルを作成するためです。 この後、実際の動画視聴ログを集計した動画視聴回数のテーブルとこのベーステーブルを LEFT JOIN します。すると、たとえ特定の日にあるレシピが1回も視聴されなかったとしても、視聴数「0」のデータとしてきちんと残すことができるのです。これにより、時系列グラフが途切れることなく、正確なトレンド分析が可能になります。 CROSS JOIN は、時系列分析におけるデータ欠損を防ぐための、非常に重要なテクニックでした。 データを"見える化"する:Amazon QuickSightの活用 データパイプラインの最終工程は、加工したデータをビジネスの意思決定に繋げるための「可視化」です。ここで活躍したのが、AWSが提供するBIサービス、 Amazon QuickSight です。 QuickSightは、Amazon AthenaやDatabricksといったデータソースに直接接続し、ドラッグ&ドロップの直感的な操作でデータをグラフや表に変換し、インタラクティブなダッシュボードを高速に作成できるクラウドサービスです。 今回はQuickSightを使って、 タグ(ジャンル)ごとの視聴数推移を示す折れ線グラフ 期間内で人気の高かったレシピのランキング表 などを盛り込んだダッシュボードを作成しました。(下の写真)これにより、ユーザーがボタン一つで最新のトレンドを把握し、メーカー・小売への提案に活かせるようになりました。 インターンシップを終えて:学びと今後の展望 この1ヶ月で得た大きな学び このインターンシップは、私にとって初めて尽くしの1ヶ月でした。特に、技術面とマインド面で大きな成長があったと感じています。 ビッグデータと常に隣り合わせの最適化思考 : 個人開発では意識することのなかった「データの巨大さ」に、良い意味で何度も殴られました。1つのクエリでもかなりの時間がかかることもあり、高速に結果を出すためのクエリパフォーマンスの最適化の視点が常に求められました。これは、データエンジニアリングの根幹に触れる貴重な経験でした。 「自分のため」から「チームのため」の開発へ : git push する指が震えたのも良い思い出です。ブランチ戦略、プルリクエストでの丁寧な説明、レビューでの的確な指摘。その全てが、「コードは書いた人だけのものではなく、チームの資産である」ということを教えてくれました。他人が読むことを前提とした、可読性の高いコードを書く意識が叩き込まれました。 ユーザーのインサイトを「先回り」するプロダクト開発 : 機能開発の背景にあったように、プロダクトは常にユーザーの課題解決のためにあります。「こんな機能があったら便利だろう」という仮説を立て、ヒアリングを通して検証し、ユーザーの思考を先回りして価値を提供する。この一連のプロセスを間近で体験できたことは、今後のキャリアの礎になるはずです。 これから挑戦したいこと:「なぜ」を解き明かすトレンド分析へ 今回開発した機能は、食のトレンドを可視化する大きな一歩となりました。しかし、同時に新たな課題と、より大きな可能性も見えてきました。 動画視聴データは、テレビ番組での紹介やSNSでの拡散によって、ごく短時間で爆発的に再生数が伸びる(バズる)という特徴があります。また、季節性の変動も大きく、実は多くの要素が絡み合って複雑になっています。現状では、データの膨大さからダッシュボードの表示に時間がかかってしまうというパフォーマンス面の課題も残っています。 そして何より、今後挑戦したいのは、「何が流行っているか」を提示するだけでなく、「なぜそれが流行っているのか」というインサイトまで提供することです。 例えば、「"鶏むね肉"の視聴数が急増した」という事実だけでなく、「それは、人気テレビ番組で"節約"をテーマにした特集が組まれ、SNSで"簡単でおいしい鶏むね肉レシピ"という投稿が拡散されたからだ」という背景までを解き明かしたい。いわば、 バズの因数分解 です。 この「なぜ」を解き明かすことができれば、デリッシュリサーチはメーカー・小売に対して、より精度の高い需要予測や、効果的な販売戦略の立案をサポートできるはずです。 最後になりますが、1ヶ月間温かく、そして熱心にご指導くださったメンターの皆様、デリッシュリサーチ部門の皆様、本当にありがとうございました!
アバター
はじめに こんにちは! 2025年の夏、株式会社エブリーで1ヶ月間インターンシップに参加している常木泰成です。配属されたのは、国内最大級のレシピ動画メディア「デリッシュキッチン」のデータを活用して食のトレンドを分析し、メーカー・小売の意思決定をサポートする「デリッシュリサーチ」部門です。 この1ヶ月間で私が取り組んだのは、 動画視聴データを用いた新たなトレンド分析機能の開発 です。この記事では、ユーザーの隠れたニーズから機能開発の背景、そして人生初のSQLと格闘しながらデータを可視化するまでの技術的な裏側を伝えていければと思います。 背景 なぜ「動画視聴データ」の分析が必要だったのか? デリッシュキッチンアプリの検索データ・レシピ閲覧データをもとに、消費者の食トレンドや興味関心を分析できるツールです。消費者分析や調査などの工数削減や、お客様のニーズを踏まえた商品開発/販促を実現でき、施策の確度も高めることが可能です。 すでに検索頻度ランキングなどの機能は存在していましたが、既存ユーザーへのヒアリングから、さらに深いインサイトを求める声が明らかになってきました。 ユーザーが本当に知りたかったこと: 「今週、『鶏肉』の検索が増えた」という事実( 点 )だけでなく、「なぜ増えたのか?具体的にどんな 鶏肉料理 (ジャンル)が人気なのか?」( 線 )を知りたい。 特定のレシピが人気な理由、例えば「このハンバーグが人気なのは、 "時短" や "節約" といったタグが響いているからではないか?」という背景( 面 )を捉えたい。 消費者が「今、本当に作りたい料理」のトレンドを、より解像度高く把握したい。 これらのニーズは、単一のレシピ人気を追うだけでは満たせません。そこで必要になってくるのが 動画視聴データ とレシピに付与された タグ です。 ユーザーがどのレシピ動画をいつ視聴したか、そしてそのレシピにどんなタグ(例:「#簡単」「#鶏むね肉」「#お弁当」)が付いているかを掛け合わせることで、 特定のタグを持つレシピ群が、いつ、どれくらい見られているか を時系列で可視化できます。これにより、「点」の分析から脱却し、トレンドの「線」と「面」を捉えるインサイトを提供できると考えました。 この仮説に基づき、 指定したタグに合致するレシピの視聴トレンドを時系列グラフで可視化し、期間内で特に人気だったレシピをランキング形式で提示する 、という新機能の開発プロジェクトがスタートしました。 データ加工 初めてのSQL 今回の開発では、データ基盤として Databricks を利用しました。デリッシュキッチンから送られてくる膨大な視聴ログデータを、SQLを使って抽出・加工し、BIツールであるAmazon QuickSightでビジュアルを作成するのが主な流れです。 ここで私の前に立ちはだかったのが、 人生初のSQL でした。最初はSELECT * FROM ...すらおぼつかない状態でしたが、エブリーに代々受け継がれる「SQLもくもく資料」と、メンターの熱心なご指導のおかげで、最終的には最低限のリファレンス込みで複雑なクエリを読み書きできるレベルに成長できました! SQLか、Python(Pandas)か? データ加工と聞いて、最初は「SQLを使用しなくても、PythonのPandasライブラリでも同じことができるのでは?」と考えていました。しかし、インターンを通じて、大規模データを扱う上でのSQLの優位性を感じることが何度もありました。 データ集計とクエリの最適化 SQLは、データベースから必要なデータを効率的に抽出・集計するために最適化された言語です。GROUP BY, JOIN, WHEREといった命令は、データベースエンジンが最も効率的な実行計画を立てて処理してくれるため、テラバイト級のデータに対しても非常に高速です。一方、Pandasはデータを一度メモリに全て読み込んでから処理するため、データサイズが大きくなるとメモリ不足に陥ったり、パフォーマンスが著しく低下したりする可能性があります。 データの整合性 データベースでは、スキーマ定義(PRIMARY KEY, FOREIGN KEYなど)によって、データの重複や不正な値をシステムレベルで防いでくれます。これにより、データの品質が担保され、アプリケーション側は安心してデータを扱えます。もしこれをPythonのコードだけで実現しようとすると、あらゆる処理に整合性チェックのロジックを自前で実装する必要があり、非常に煩雑になります。 効率的な差分更新とトランザクション 今回のプロジェクトでは、毎日増え続ける視聴ログデータを効率的に集計テーブルへ反映させる必要がありました。もしPandasで実装するなら、「過去の全データを読み込み、今日のデータを追加し、全データを書き出す」という非効率な処理になりがちです。しかし、Databricksが採用している Delta Lake という技術と、それを操作するPySparkコードを使えば、この課題を解決できます。 実際に書いたコードがこちらです。 # 毎日増分の更新をする column_selected_filtered_data \ .write \ .format( "delta" ) \ .mode( "overwrite" ) \ .partitionBy( "event_date" ) \ .option( "replaceWhere" , f "event_date >= '{event_date}'" ) \ .save( "delta_table_path/sample_analytics_table" ) 注: 実際の開発では、弊社内のデータ基盤に合わせてテーブル名やスキーマ構造を調整しています。 このコードは、単にデータを上書きしているのではありません。 partitionBy("event_date") : まず、データが event_date (日付)ごとに物理的に分割して保存されています。 option("replaceWhere", ...) : そして、このオプションで「 event_date が指定した日付以降のデータのみを置き換える」という指示を出しています。 これにより、過去の膨大なデータを一切触ることなく、直近のデータだけを更新できます。ファイルベースで処理を行うPandas単体では、同様の効率性と安全性を担保するのは非常に困難です。 SQLコード解説 インターン中に書いた、特に印象的だったクエリの一部をご紹介します。 SELECT l.event_date, r.recipe_id, CONCAT(r.lead, r.title) AS title, r.image, r.tags, r.ingredients, r.category, r.taste FROM distinct_event_dates AS l CROSS JOIN recipe_master AS r WHERE r.advertiser_id IS NULL このクエリの主役は CROSS JOIN です。SQL初学者だった私は最初この仕組みの理解に苦しみました。 CROSS JOIN は、2つのテーブルの全レコードを総当たりで組み合わせる結合方法です。このクエリでは、 distinct_event_dates (分析対象期間の日付リストを持つテーブル)と recipe_master (全てのレシピ情報を持つマスタテーブル)を掛け合わせています。 なぜこんなことをするのか? これは、「分析期間中のすべての日付 × すべてのレシピ」の組み合わせを持つ、巨大な"設計図"のようなベーステーブルを作成するためです。 この後、実際の動画視聴ログを集計した動画視聴回数のテーブルとこのベーステーブルを LEFT JOIN します。すると、たとえ特定の日にあるレシピが1回も視聴されなかったとしても、視聴数「0」のデータとしてきちんと残すことができるのです。これにより、時系列グラフが途切れることなく、正確なトレンド分析が可能になります。 CROSS JOIN は、時系列分析におけるデータ欠損を防ぐための、非常に重要なテクニックでした。 データを"見える化"する:Amazon QuickSightの活用 データパイプラインの最終工程は、加工したデータをビジネスの意思決定に繋げるための「可視化」です。ここで活躍したのが、AWSが提供するBIサービス、 Amazon QuickSight です。 QuickSightは、Amazon AthenaやDatabricksといったデータソースに直接接続し、ドラッグ&ドロップの直感的な操作でデータをグラフや表に変換し、インタラクティブなダッシュボードを高速に作成できるクラウドサービスです。 今回はQuickSightを使って、 タグ(ジャンル)ごとの視聴数推移を示す折れ線グラフ 期間内で人気の高かったレシピのランキング表 などを盛り込んだダッシュボードを作成しました。(下の写真)これにより、ユーザーがボタン一つで最新のトレンドを把握し、メーカー・小売への提案に活かせるようになりました。 インターンシップを終えて:学びと今後の展望 この1ヶ月で得た大きな学び このインターンシップは、私にとって初めて尽くしの1ヶ月でした。特に、技術面とマインド面で大きな成長があったと感じています。 ビッグデータと常に隣り合わせの最適化思考 : 個人開発では意識することのなかった「データの巨大さ」に、良い意味で何度も殴られました。1つのクエリでもかなりの時間がかかることもあり、高速に結果を出すためのクエリパフォーマンスの最適化の視点が常に求められました。これは、データエンジニアリングの根幹に触れる貴重な経験でした。 「自分のため」から「チームのため」の開発へ : git push する指が震えたのも良い思い出です。ブランチ戦略、プルリクエストでの丁寧な説明、レビューでの的確な指摘。その全てが、「コードは書いた人だけのものではなく、チームの資産である」ということを教えてくれました。他人が読むことを前提とした、可読性の高いコードを書く意識が叩き込まれました。 ユーザーのインサイトを「先回り」するプロダクト開発 : 機能開発の背景にあったように、プロダクトは常にユーザーの課題解決のためにあります。「こんな機能があったら便利だろう」という仮説を立て、ヒアリングを通して検証し、ユーザーの思考を先回りして価値を提供する。この一連のプロセスを間近で体験できたことは、今後のキャリアの礎になるはずです。 これから挑戦したいこと:「なぜ」を解き明かすトレンド分析へ 今回開発した機能は、食のトレンドを可視化する大きな一歩となりました。しかし、同時に新たな課題と、より大きな可能性も見えてきました。 動画視聴データは、テレビ番組での紹介やSNSでの拡散によって、ごく短時間で爆発的に再生数が伸びる(バズる)という特徴があります。また、季節性の変動も大きく、実は多くの要素が絡み合って複雑になっています。現状では、データの膨大さからダッシュボードの表示に時間がかかってしまうというパフォーマンス面の課題も残っています。 そして何より、今後挑戦したいのは、「何が流行っているか」を提示するだけでなく、「なぜそれが流行っているのか」というインサイトまで提供することです。 例えば、「"鶏むね肉"の視聴数が急増した」という事実だけでなく、「それは、人気テレビ番組で"節約"をテーマにした特集が組まれ、SNSで"簡単でおいしい鶏むね肉レシピ"という投稿が拡散されたからだ」という背景までを解き明かしたい。いわば、 バズの因数分解 です。 この「なぜ」を解き明かすことができれば、デリッシュリサーチはメーカー・小売に対して、より精度の高い需要予測や、効果的な販売戦略の立案をサポートできるはずです。 最後になりますが、1ヶ月間温かく、そして熱心にご指導くださったメンターの皆様、デリッシュリサーチ部門の皆様、本当にありがとうございました!
アバター
はじめに こんにちは。株式会社エブリーの開発1部で部長を務めている村上です。 弊社は2023年のGithub CopilotとChatGPT plusの利用サポートから始まり、直近では4月ごろからCursorを導入し、生成AI活用に力を入れています。今回は、直近の生成AI活用推進で取り組んだ内容や、社内で実施した生成AI活用状況アンケートの結果、そして今後の取り組みについてお話しします。 直近の取り組み ツールの導入だけではなく、組織全体でAIを使いこなしていくために、直近では以下のような取り組みを行っています。 生成AI活用の知見を共有するslackチャンネルの開設 AI活用勉強会の開催 AIテーマに絞った挑戦WEEKの開催 Figma Devシートを配布し、MCPを活用したデザインからコード生成の検証 仕様書をGithub管理に移行した開発フローの検証 これらの取り組みに関して簡単に説明します。 生成AI活用の知見を共有するslackチャンネルの開設 Cursorの配布と合わせて、各自が利用しているAIツールの知見を組織で蓄積していくため、気軽にシェアできるslackチャンネルを開設しました。実際に個人のプロンプトやルール、AIツールの最新情報が共有され、メンバーの活用レベル向上にもつながっています。 現在この取り組みは全社に広がり、特定のスタンプを押すと社内のAI活用共有用チャンネルに投稿される仕組みで、会社全体のAI活用知見を蓄積しています。 AI活用勉強会の開催 社内でAI活用を積極的に取り組んでいるメンバーが数人集まり、AIツールを活用した開発効率化勉強会を主催してくれています。隔週で開催され、持ち回りで発表やハンズオンを行っています。 これまでの発表では各AIツールの使いこなし方やMCPの作り方についての内容が多いです。 勉強会の様子 AIテーマに絞った挑戦WEEKの開催 挑戦WEEKは、通常の事業部ロードマップから離れ、エンジニアが自ら提案したテーマに基づいて技術的な挑戦に集中する社内の取り組みです。普段は自由なテーマで開催していますが、今回はあえてテーマをAI活用に絞って取り組むアイデアを募集し実施しました。自作Redash MCPサーバーの作成やSentryのエラー通知をDevinで調査し実行計画を立てる仕組みを作るなど、今後のさらなるAI活用につながる成果が出ました。 詳しくはテックブログにも書いているのでぜひご覧ください。 Figma Devシートを配布し、MCPを活用したデザインからコード生成の検証 まだベータではありますが、今年の6月にFigma公式のMCPが発表されました。 このMCPはFigmaのDevシートでないとアクセスできないため、シートの配布を行いながら、デザインからアプリ画面のコード生成までを検証しました。デザイナーとエンジニアが協力しながら効果的な活用方法を工夫してくれたことで生産性も向上し、現在は正式導入して現場で使用しています。 仕様書をGithub管理に移行した開発フローの検証 弊社ではこれまで仕様書にConfluenceを活用してきましたが、改めてAI時代における仕様書管理の在り方を見直し、PdMの生産性向上やAIによる精度の高い実装を実現する方法を検討するようになりました。 もちろんConfluenceでもMCPを使用してコンテキストを読み込むことは可能でしたが、一部のチームでは以下のようなメリットがあるのではないかという仮説のもと、開発手法を変更して検証を行っています。 PdMがCursorを使ってマークダウンでの仕様書作成、編集をスムーズに行える ドキュメントのリポジトリを併用することでAIへ仕様を簡単に伝えることができ、設計やコード生成の精度が向上する ドキュメントの更新、レビューにAIツール(Devin, CodeRabbit等)を活用できる この検証はまだ途中段階で正式導入の判断には至っていませんが、振り返りのタイミングで結果を発信していきたいと思っています。 現在の生成AIの活用状況 こうした取り組みを進める中で、現在のエンジニア組織における生成AI活用状況はどうなっているでしょうか。直近で実施した生成AI活用アンケートの結果を共有します。 業務で最も利用しているAIコーディングツール アンケート結果 ツール名 割合 人数 Cursor 55.6% 15 Claude Code 40.7% 11 Android Studio Gemini 3.7% 1 選択肢には『AIツールを使っていない』という回答もできるようにしていましたが、全エンジニアがAIコーディングツールを利用している結果となりました。 利用しているAIコーディングツールでは、会社がサポートしているCursorが最も多く利用されているものの、Claude Codeの利用も活発になってきています。CursorとClaude Codeそれぞれの選択理由は以下の通りです。 Cursor 会社でサポートをもらえており、要件を満たしている 業務の中でシームレスに利用できるため 秘伝のタレが完成していてむしろ他のツールに移行するコストが高い コード補完が楽 複数のエンジニアが共通して使えるツールを、まず使えるものにしないと、結局全体の生産性は上がらない Claude Code 必要な情報を読み込んでくれて、コンテキストが埋まると圧縮してうまく情報を扱ってくれる タスク管理とかを自分でToDoを立ててうまく進めてくれる hooksやcustom commandが優秀 Opusが賢いので使いたい Terminal上で完結できるのが良い cursorより粘ってくれる 一番安定した成果出る印象がある 好きなIDEで利用できる AIコーディングツールの利用頻度 アンケート結果 頻度 割合 人数 ほぼ毎日、常に利用している 77.8% 21 週に2〜3回程度 18.5% 5 週に1回程度 3.7% 1 約8割のエンジニアが毎日利用している状況でした。現在、エンジニアにとってAIコーディングツールが必要不可欠な存在になっていることが分かります。 AIコーディングツールの利用目的 複数選択の形式でどんな目的で利用しているかを回答してもらいました。 目的 割合 人数 コードの新規作成・スニペット生成 96.3% 26 既存コードの修正・リファクタリング 77.8% 21 コードのデバッグ・エラー原因の特定 74.1% 20 ドキュメントやコメントの自動生成 59.3% 16 仕様からのコード実装 48.1% 13 技術的な調査や学習の補助 59.3% 16 テストコードの作成 48.1% 13 多少の差はありますが、全体としてエンジニアのほとんどの業務シーンでAIを使っていることが分かります。 開発全体の生産性はどの程度変化したか 生産性 割合 人数 生産性2倍以上向上した 48.1% 13 生産性1.5倍程度向上した 33.3% 9 生産性1.2~1.3倍程度向上した 14.8% 4 変わらない 3.7% 1 約半数のエンジニアが『生産性が2倍以上向上した』と回答しており、大きな効果が見られています。全体を見ても2倍以上には至らないものの、ほとんどのエンジニアが生成AIによって生産性が向上したと回答しました。これは定性面だけでなく、以前にCTOの今井が執筆したブログでもPR数が2倍になったことが報告されており、生産性測定の完全な指標ではないものの、定量面でも裏付けとなる結果が出ています。 試してみたいAIツールや会社に求めるAIサポート 利用状況アンケートの他に、今後のAI活用を考える上で試してみたいツールや会社に求めるサポートについても、任意の自由記述アンケートを実施しました。 試してみたいAIツールでは以下のようなツールが上がってきました。特にAIレビュー系のツールを使ってみたいという声が多かったです。 CodeRabbit などのAIレビュー系ツール Autify、MagicPodなどのQAツール (リリースされたら)Xcodeのコーディングインテリジェンス機能 また、会社に求めるサポートとして圧倒的に多かったのが、利用ツール選定において個人に一定の自由度を設け、様々なツールをサポートしてほしいという声でした。この背景には、弊社がCursorサポートに限定してスタートしたことがあり、エンジニア組織としてもサポートの在り方や予算の組み方を見直す必要性を感じました。 結果を踏まえた現在の取り組み 現在はアンケート結果や現場での課題感を踏まえ、以下のような取り組みを始めています。 AIレビューツールの検証 アンケートで多くの人がAIレビュー系ツールに高い関心を示していることが分かりました。また、今後AIによるアウトプット量が増加する中で、人間のレビュー負荷軽減は非常に重要なテーマとなるため、エンジニア組織としてもこの分野への投資を加速すべく検証を始めています。 すでにCodeRabbitの検証は完了しており、Github CopilotやCursor Bugbotのレビューと比較しても精度が高く、全チームから導入希望の声をもらっています。AIレビュー特化ツールならではの強みを実感しており、今後は競合ツールである Greptile の検証も行い、比較検討を進める予定です。 より柔軟なAIツールのサポート 会社へのサポート要望でもありましたが、今後AIツール間の競争が激化し、より優れた新しいツールが登場する中で、特定ツールに限定したサポートは良い戦略ではないのではないかという課題意識がありました。また、現状のCursor利用実態を見ると、AIを積極活用するエンジニアはすぐに高速リクエストの上限に達してしまうため、従量課金でのサポート追加の必要性も感じていました。 そこでサポート費用を拡大し、個人が利用するAIツールを選択できるようにしつつ、割り当て範囲内での従量課金も必要に応じてサポートしていく方針です。組織として利用するDevinやCodeRabbitといったツールは別途導入する前提で、個人向けには以下のツールをサポートしていきます。 Cursor Claude Code Firebender ツールはこれらに限定せず、新しく有用なツールが登場すれば随時導入検討や追加を行い、常にその時点で最も理想的な選択肢を提供できる状態を目指していきます。 終わりに 今回は、生成AI活用における会社での取り組みと現在の活用状況についてお話ししました。 今後も私たちは「開発生産性10倍」を目標に掲げ、エンジニアがより本質的な価値創造に集中できるよう、AI活用も積極的に推進していきます。AIの発展により2-3ヶ月先には実現可能なことや利用ツールも変わっている可能性がありますが、今回紹介したように技術の変化や社内の活用状況を把握しながら、柔軟に適応し続ける組織でありたいと思います。 生成AIでプロダクト開発をアップデートしたい方、 生成AIを活用したプロダクトを作りたい方、 ぜひ弊社で一緒に働きましょう!
アバター
はじめに デリッシュキッチンのiOSアプリを開発している成田です。 アプリで画像処理を行う場合、そのパフォーマンスは非常に重要です。 画像処理を行うケースとして、例えばユーザーが送信した画像を使って何かしたいといった時に、「予めローカルで画像の平均輝度を算出し、解析できないほど暗すぎる画像は弾く」といったユースケースがあります。 そして、平均輝度を計算する時に単純にCPUで処理するか、GPUで処理するのかで大きくパフォーマンスが異なります。 本記事では、以下の3つのアプローチで実際にiOSアプリで画像処理のパフォーマンス比較をしてみたいと思います。 CPUベースの逐次処理 Core Imageフレームークを利用したGPU処理 Metalを直接利用したGPU処理(おまけ) それぞれのアプローチを紹介した後、最後にまとめてデモアプリを使ってサンプル画像の処理時間を比較してみようと思います。 今回算出する輝度について 輝度とは本来、光の強さなど明るさを表す物理量を意味しますが、画像で輝度を求める場合は、sRGB色空間における相対輝度を用います。 sRGB色空間における相対輝度とは、最も暗い黒を0、最も明るい白を1として正規化した、色空間内における相対的な明るさのことです。 ここで扱う RGB 値は 8bit(0〜255) で表現されるピクセル値です。 これを0〜1に正規化して、次の重み付き和で計算します。 この式を画像全体に適用して平均を取ったものが「平均輝度」となります。 1.CPUでの処理 最もシンプルなのは、for文でループを回してピクセルごとに輝度を計算する方法です。 しかし、iPhoneのCPUはシングルスレッド的に処理していくため、数百万画素以上などの画像を扱ったりすると一気に処理が重くなっていきます。 func calculateAverageLuminanceCPU (from uiImage : UIImage ) -> ( result: Float , time: TimeInterval ) { // 処理開始時間を記録 let startTime = CFAbsoluteTimeGetCurrent() // UIImageからCGImageを取得 guard let cgImage = uiImage.cgImage else { return ( 0 , 0 ) } // 画像の幅・高さを取得 let width = cgImage.width let height = cgImage.height // ピクセルあたりのバイト数(RGBAなので4) let bytesPerPixel = 4 // 1行あたりのバイト数 let bytesPerRow = bytesPerPixel * width // 1色成分あたりのビット数(8bit) let bitsPerComponent = 8 // ピクセルデータを格納する配列を用意(RGBA順) var pixelData = [UInt8](repeating : 0 , count : width * height * bytesPerPixel) // RGB色空間を作成 let colorSpace = CGColorSpaceCreateDeviceRGB() // CGContext を作成してピクセルデータ配列に描画 let context = CGContext(data : & pixelData, width : width , height : height , bitsPerComponent : bitsPerComponent , bytesPerRow : bytesPerRow , space : colorSpace , bitmapInfo : CGImageAlphaInfo.noneSkipLast.rawValue ) // CGImage を CGContext に描画 context?.draw(cgImage, in : CGRect (x : 0 , y : 0 , width : width , height : height )) // 輝度の合計値を格納する変数 var totalLuminance : Float = 0.0 let pixelCount = width * height // ループで画像の全てのピクセルを処理 for i in 0 ..< pixelCount { let pixelIndex = i * bytesPerPixel // RGB値を0〜1に正規化し、sRGB → 線形輝度に変換 let r = sRGBToLinear(Float(pixelData[pixelIndex]) / 255.0 ) let g = sRGBToLinear(Float(pixelData[pixelIndex + 1 ]) / 255.0 ) let b = sRGBToLinear(Float(pixelData[pixelIndex + 2 ]) / 255.0 ) // 輝度を計算 let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b totalLuminance += luminance } // 平均輝度を計算 let averageLuminance = totalLuminance / Float(pixelCount) // 処理終了時間を取得して経過時間を計算 let endTime = CFAbsoluteTimeGetCurrent() // 平均輝度と処理時間を返す return (averageLuminance, endTime - startTime) } 2. Core ImageでのGPU処理 Core ImageはAppleが提供する画像処理フレームワークです。 多数の組み込みフィルタを使用して画像や動画を処理でき、またフィルタを連携させることで複雑なエフェクトも構築できます。画像のぼかしやシャープ化、色味や彩度の調整用など多くのフィルタが用意されています。 平均輝度を求める場合も CIAreaAverage フィルタを使えばワンライナーで計算可能です。 このフィルターを使うと全ピクセルの平均色を計算して1×1ピクセルの画像として返してくれます。 内部ではGPUの巨大なスレッド群で全ピクセルを複数のグループで並列に処理して平均色を算出していると考えられます。 func calculateAverageLuminanceCoreImage (from uiImage : UIImage ) -> ( result: Float , time: TimeInterval ) { // 処理開始時間を記録 let startTime = CFAbsoluteTimeGetCurrent() // UIImage から CGImage を取得 guard let cgImage = uiImage.cgImage else { return ( 0 , 0 ) } // CGImage から CIImage を作成(Core Image フィルタで処理するため) let ciImage = CIImage(cgImage : cgImage ) // CIAreaAverage フィルターを使用して平均色を取得 // CIAreaAverage は指定領域の平均色を計算して 1x1 ピクセル画像を返すフィルタ guard let areaAverageFilter = CIFilter(name : "CIAreaAverage" ) else { return ( 0 , 0 ) } // フィルターに入力画像をセット areaAverageFilter.setValue(ciImage, forKey : kCIInputImageKey ) // フィルターを適用する範囲を画像全体に設定 areaAverageFilter.setValue(CIVector(cgRect : ciImage.extent ), forKey : kCIInputExtentKey ) // フィルターの出力画像を取得 guard let outputImage = areaAverageFilter.outputImage else { return ( 0 , 0 ) } // 出力画像は 1x1 ピクセルなので、ここから RGBA データを取得 var pixelData = [UInt8](repeating : 0 , count : 4 ) ciContext.render(outputImage, toBitmap : & pixelData, rowBytes : 4 , bounds : CGRect (x : 0 , y : 0 , width : 1 , height : 1 ), format : .RGBA8, colorSpace : CGColorSpaceCreateDeviceRGB ()) // ピクセル値は UInt8 (0〜255) なので 0〜1 に正規化し、 // sRGB → 線形輝度に変換(ガンマ補正の逆変換) let r = sRGBToLinear(Float(pixelData[ 0 ]) / 255.0 ) let g = sRGBToLinear(Float(pixelData[ 1 ]) / 255.0 ) let b = sRGBToLinear(Float(pixelData[ 2 ]) / 255.0 ) // 相対輝度を計算 let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b // 処理終了時間を記録 let endTime = CFAbsoluteTimeGetCurrent() // 平均輝度と処理時間を返す return (luminance, endTime - startTime) } 3. MetalでのGPU処理 MetalとはAppleが提供する低レベルのAPIで、GPUにアクセスして独自のグラフィック処理などを行うことができます。 Metalを直接利用すると、スレッド数や並列化単位を自分で設計したりできます。 これにより、GPUの並列性能を大きく引き出すことが可能です。 独自のカスタムがしやすい分、実装量はかなり大きいです。 まず、GPUで平均輝度を計算するための関数を定義する必要があります。 ファイルの拡張子はmetalです。 Swiftではなく、Metal Shading LanguageというMetal用の言語で書きます。 ここでGPU上で動くプログラムを定義します。 #include < metal_stdlib > using namespace metal; // 平均輝度を計算する関数 kernel void calculateAverageLuminanceLinear(texture2d < float, access :: read > inputTexture [[texture( 0 )]], device atomic < float >* resultBuffer [[buffer( 0 )]], uint2 gid [[thread_position_in_grid]]) { uint width = inputTexture.get_width(); uint height = inputTexture.get_height(); // テクスチャの境界チェック if (gid.x >= width || gid.y >= height) { return ; } // ピクセルの色を読み取り float4 color = inputTexture.read(gid); // sRGB -> Linear変換 float r = sRGBToLinear(color.r); float g = sRGBToLinear(color.g); float b = sRGBToLinear(color.b); float luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; // 累積輝度を計算 atomic_fetch_add_explicit(resultBuffer, luminance, memory_order_relaxed); } 次に上記で定義したものを使ってMetalの初期化します。 init () { setupMetal() } private func setupMetal () { // デフォルトのMetalデバイス(GPU)を取得 metalDevice = MTLCreateSystemDefaultDevice() // GPUに送るコマンドキューを作成 metalCommandQueue = metalDevice?.makeCommandQueue() guard let device = metalDevice else { print( "Metal device not available" ) return } do { // プロジェクト内のMetalシェーダライブラリを取得 let library = device.makeDefaultLibrary() // ライブラリから計算用関数を取得 let function = library?.makeFunction(name : "calculateAverageLuminanceLinear" ) // 取得した関数をGPUで実行可能なパイプラインに変換 metalComputePipelineState = try device.makeComputePipelineState(function : function! ) } catch { print( "Failed to create Metal pipeline state: \( error ) " ) } } func calculateAverageLuminanceMetal (from uiImage : UIImage ) -> ( result: Float , time: TimeInterval ) { // 処理開始時間を記録 let startTime = CFAbsoluteTimeGetCurrent() guard let device = metalDevice, let commandQueue = metalCommandQueue, let pipelineState = metalComputePipelineState, let cgImage = uiImage.cgImage else { return ( 0 , 0 ) } let width = cgImage.width let height = cgImage.height let bytesPerPixel = 4 // テクスチャ作成 // Metal上で画像データを扱うためのテクスチャを作成 let textureDescriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat : .rgba8Unorm, width : width , height : height , mipmapped : false ) textureDescriptor.usage = [.shaderRead] guard let inputTexture = device.makeTexture(descriptor : textureDescriptor ) else { return ( 0 , 0 ) } // CGImageからテクスチャにデータコピー // CGContextを使ってCGImageのピクセルデータを配列に読み込み let colorSpace = CGColorSpaceCreateDeviceRGB() var pixelData = [UInt8](repeating : 0 , count : width * height * bytesPerPixel) let context = CGContext(data : & pixelData, width : width , height : height , bitsPerComponent : 8 , bytesPerRow : bytesPerPixel * width, space : colorSpace , bitmapInfo : CGImageAlphaInfo.noneSkipLast.rawValue ) context?.draw(cgImage, in : CGRect (x : 0 , y : 0 , width : width , height : height )) // ピクセルデータをテクスチャにコピー inputTexture.replace(region : MTLRegionMake2D ( 0 , 0 , width, height), mipmapLevel : 0 , withBytes : & pixelData, bytesPerRow : bytesPerPixel * width) // 結果用バッファ作成 // 平均輝度を格納するためのFloatバッファ guard let resultBuffer = device.makeBuffer(length : MemoryLayout < Float > .size, options : [] ) else { return ( 0 , 0 ) } // バッファを0で初期化 let bufferPointer = resultBuffer.contents().bindMemory(to : Float.self , capacity : 1 ) bufferPointer[ 0 ] = 0.0 // コマンドバッファとコンピュートエンコーダ作成 guard let commandBuffer = commandQueue.makeCommandBuffer(), let computeEncoder = commandBuffer.makeComputeCommandEncoder() else { return ( 0 , 0 ) } // パイプラインステート、テクスチャ、結果バッファをセット computeEncoder.setComputePipelineState(pipelineState) computeEncoder.setTexture(inputTexture, index : 0 ) computeEncoder.setBuffer(resultBuffer, offset : 0 , index : 0 ) // スレッドグループの設定 // 16x16のスレッドブロックで分割して計算 let threadgroupSize = MTLSize(width : 16 , height : 16 , depth : 1 ) let threadgroupCount = MTLSize(width : ( width + threadgroupSize.width - 1 ) / threadgroupSize.width, height : ( height + threadgroupSize.height - 1 ) / threadgroupSize.height, depth : 1 ) // 「このスレッドグループで計算してね」という命令をコマンドバッファに記録 computeEncoder.dispatchThreadgroups(threadgroupCount, threadsPerThreadgroup : threadgroupSize ) computeEncoder.endEncoding() // GPUでの処理実行 commandBuffer.commit() commandBuffer.waitUntilCompleted() // 結果取得 let resultPointer = resultBuffer.contents().bindMemory(to : Float.self , capacity : 1 ) // 累積輝度をピクセル数で割って平均輝度を算出 let result = resultPointer[ 0 ] / Float(width * height) // 処理終了時間を記録 let endTime = CFAbsoluteTimeGetCurrent() // 平均輝度と処理時間を返す return (result, endTime - startTime) } 実際に処理速度を比較してみる 上記3つの処理速度を実際に計算するためにデモアプリを作ってみました。 画像を選択して実行ボタンを押すと3通り順次平均輝度を算出して全てが完了したら結果が出力されます。 実行結果はこちらになりました。 今回使用した画像は約1200万画素ですが、CPUとGPUを使った場合で処理速度に明確な差を確認することができました! また、Core Imageを使った場合とMetalを直接使った場合では大きな差はみられませんでした。 今回はシンプルな平均輝度計算だったのでCore ImageとMetalではそこまで差がつかないのかもしれません。 まとめ CPU処理 : 実装は簡単だがシングルスレッド的処理になるので非常に遅い Core Image : 抽象度が高く、簡単かつ高速。標準的な処理ならベスト Metal : 最も柔軟で速いが、学習コスト・実装コストは高い iPhone端末のスペックも年々向上しているため、普段のアプリ開発ではGPUをあまり意識しなくても問題ないケースが多いと思います。 しかし、グラフィック処理といった分野では、一度に扱うデータ量が膨大になり、処理が重くなりやすいのも事実です。 だからこそ、CPUとGPUの役割や得意分野を理解し、必要に応じてうまく使い分けることが、快適なアプリ体験を実現する上で大切になります。
アバター
はじめに こんにちは、リテールハブ開発部の杉森です。 私たちのチームでは、既存のバッチサーバーに対して、EC2インスタンスからECS on Fargateへの移行を検討しています。 しかし、「コスト面で大きなデメリットがあるのではないか?」という疑問が挙がっていました。 本記事では、実際のバッチサーバーの運用パターンを想定し、EC2インスタンスとECS on Fargateの料金に関して、コスト面での比較を実施します。 また、AWS Pricing MCP Serverを活用したAIによる料金計算で同様の結果が算出できるかの検証も行います。 EC2インスタンスとECSの料金比較 基本となる仕様 バッチサーバーの仕様 バッチ内容 : 15分に1回実行される短時間定期実行バッチ 実行時間 : 5分程度(タスク起動から停止までを含む) ※EC2インスタンスの場合常時稼働を前提にしています インフラ周りの仕様 タスク要件 : CPU 2vCPU、メモリ4GB(t4g.medium 1台分のリソース) ECSタスク定義 : 1タスクあたり2vCPU、4GBメモリを使用 OS : Linux CPU アーキテクチャ : ARM リージョン : アジアパシフィック(東京) ストレージ : 20GB(EC2インスタンスの場合はEBS gp3、Fargateの場合はデフォルトの範囲) リージョン : 東京(ap-northeast-1) EC2インスタンスとECS on Fargateの料金体系 (2025年8月21日時点の料金) EC2インスタンス インスタンス(オンデマンドでt4g.medium):$0.0432/時間 EBS: $0.096/GB/月 ECS on Fargate vCPU: $0.04045/時間 メモリ: $0.00442/GB/時間 料金比較結果 EC2インスタンスの場合 インスタンス名: t4g.medium (2vCPU, 4GB) EBS: 20GB gp3 稼働時間: 24時間 × 30日 = 720時間 料金計算: - インスタンス: $0.0432 × 720時間 = $31.104 - EBS: $0.096 × 20GB = $1.92 月額合計: $33.024 参考: https://aws.amazon.com/jp/ec2/pricing/on-demand/ Fargateの場合 タスク構成: 2vCPU, 4GB メモリ 実行回数: 96回/日 × 30日 = 2,880回/月 実行時間: 5分/回 = 0.0833時間/回 総稼働時間: 0.0833時間 × 2,880回 = 240時間/月 料金計算: - vCPU: $0.04045 × 2 × 240時間 = $19.416 - メモリ: $0.00442 × 4 × 240時間 = $4.2432 月額合計: $23.6592 参考: https://calculator.aws/#/createCalculator/Fargate (おまけ)稼働時間別の料金比較 バッチの実行時間において、どの程度であればFaragateがコスト面で優位なのかを計算しました。 稼働時間/日 EC2インスタンス月額 Fargate月額 差額 優位な構成 8時間(本ケース) $33.02 $23.66 -$9.36 Fargate 11時間 $33.02 $32.53 -$0.49 Fargate 12時間 $33.02 $35.49 +$2.47 EC2インスタンス 24時間 $33.02 $70.98 +$37.96 EC2インスタンス 料金計算におけるAI活用 上記の内容を手動でまとめるのはかなりの手間です。 生産性効率化のため、AIを活用して自動化できないか検証します。 検証内容 Claude Codeを利用し、自然言語ベースでより正確な料金比較の内容を出力できないかを検証します。 今回の検証でAWS Pricing MCP Serverを利用します。 AWS Pricing MCP Server とは? AWS Pricing MCP Serverは、Model Context Protocol (MCP) 準拠のツールサーバーです。 MCPが利用可能なAIツールから、EC2インスタンス/ECS/EBSなどの最新の公開価格を取得できます。 実行環境 AIツール:Claude Code モデル:Opus4.1 MCPサーバーの設定 下記内容を .claude/setting.local.json に保存 { " mcpServers ": { " aws-pricing-mcp-server ": { " command ": " uvx ", " args ": [ " awslabs.aws-pricing-mcp-server@latest " ] , " env ": { " FASTMCP_LOG_LEVEL ": " ERROR ", " AWS_PROFILE ": " ${{AWSプロファイル}} ", " AWS_REGION ": " ${{リージョン}} " } } } } 検証結果 プロンプト EC2インスタンス t4g.mediumインスタンスを東京リージョンで月720時間稼働させた場合と、ECS Fargate(2vCPU、4GBメモリ、ARM)を15分に1回、各5分間実行で月240時間稼働させた場合の料金を比較してください。 ストレージに関しては、EC2インスタンスの場合、EBSは20GB gp3を使用し、ECSの場合無料の範囲で運用します。 AWS Pricing MCP Server を利用してください。 深く考えて。 出力された内容 ※一部省略しています サービス 月額料金(USD) 月額料金(円) 削減率 EC2インスタンス t4g.medium (ARM) $33.024 約4,953円 ベースライン ECS Fargate (ARM) $23.659 約3,549円 28.4%削減 追加のプロンプト 稼働時間で % が変動するかと思いますが、| 稼働時間/日 | EC2インスタンス月額 | Fargate月額 | 差額 | 優位な構成 | の項目に沿った表を生成して。 出力された内容 ※一部省略しています 稼働時間/日 EC2インスタンス月額 Fargate月額 差額 優位な構成 1時間 $33.02 $2.96 -$30.06 Fargate(91%削減) 2時間 $33.02 $5.91 -$27.11 Fargate(82%削減) 3時間 $33.02 $8.87 -$24.15 Fargate(73%削減) 4時間 $33.02 $11.83 -$21.19 Fargate(64%削減) 5時間 $33.02 $14.79 -$18.23 Fargate(55%削減) 6時間 $33.02 $17.74 -$15.28 Fargate(46%削減) 7時間 $33.02 $20.70 -$12.32 Fargate(37%削減) 8時間 $33.02 $23.66 -$9.36 Fargate(28%削減) 9時間 $33.02 $26.62 -$6.40 Fargate(19%削減) 10時間 $33.02 $29.57 -$3.45 Fargate(10%削減) 11時間 $33.02 $32.53 -$0.49 Fargate(1%削減) 12時間 $33.02 $35.49 +$2.47 EC2インスタンス(7%削減) 13時間 $33.02 $38.45 +$5.43 EC2インスタンス(16%削減) 14時間 $33.02 $41.40 +$8.38 EC2インスタンス(25%削減) 15時間 $33.02 $44.36 +$11.34 EC2インスタンス(34%削減) 16時間 $33.02 $47.32 +$14.30 EC2インスタンス(43%削減) 18時間 $33.02 $53.23 +$20.21 EC2インスタンス(61%削減) 20時間 $33.02 $59.15 +$26.13 EC2インスタンス(79%削減) 22時間 $33.02 $65.07 +$32.05 EC2インスタンス(97%削減) 24時間 $33.02 $70.98 +$37.96 EC2インスタンス(115%削減) 手動で計算した内容と同じ結果を得ることができました! MCPサーバーを接続しない状態で検証したところ、リソースごとの金額が適切ではない値で計算されていたため、AWS Pricing MCP Serverを繋ぐことは有効であることがわかりました。 (生成AIを使う以上必ず正しい値が算出されるわけではないですが、試算程度であればかなり有用であると感じました) まとめ バッチサーバーのインフラ選定においての料金比較とAWS Pricing MCP Serverを利用した効率化の検証を実施することができました。 他にも、「システム要件による制約」、「その他のAWSリソースの料金」、「人間による運用コスト」 など 選定における観点はさまざまありますが、インフラ費の算出で参考になれば幸いです。
アバター
はじめに こんにちは、 @きょー です!普段はデリッシュキッチン開発部のバックエンド中心で業務をしています。 AWS の CloudFront は CDN としての基本的な機能があるだけでなく「エッジで処理を実行できる」機能もあることをご存知でしょうか? CloudFront には主に 2 つのエッジ実行機能があります。 CloudFront Functions - 軽量・高速な処理用 Lambda@Edge - 複雑な処理用 この記事では、CloudFront Functions を初めて使った際に遭遇した「JavaScript の制約」について、実体験を基に詳しく解説します。特に const や exports が使えないという、JavaScript 開発者が陥りがちな罠と、その解決策をお伝えします。 CloudFront Functions とは何か? CloudFront Functions は、CloudFront に組み込まれた 軽量・高速 なエッジ実行機能です。 公式ドキュメントによると、以下の特徴を持ちます。 大規模でレイテンシーの影響を受けやすい CDN カスタマイズのための軽量な関数を JavaScript で記述できます。CloudFront Functions の runtime 環境は、起動時間が 1 ミリ秒未満、毎秒数百万のリクエストを処理するようにすぐにスケールでき、高い安全性を誇ります。 出典: AWS CloudFront Developer Guide - CloudFront Functions と Lambda@Edge の違い CloudFront Functions が最適な用途 AWS が推奨する主な用途 キャッシュキー正規化 HTTP リクエスト属性(ヘッダー、クエリ文字列、Cookie、URL パス)を変換 最適なキャッシュキーを作成してキャッシュヒット率を向上 ヘッダー操作 リクエストやレスポンスで HTTP ヘッダーの挿入、変更、削除 例: True-Client-IP ヘッダーの追加 URL リダイレクト・リライト リクエスト情報に基づくビューアーの他ページへのリダイレクト 特定パスから別パスへのリクエストリライト リクエスト認証 ハッシュ化された認証トークン(JWT 等)の検証 認証ヘッダーや他のリクエストメタデータの検証 CloudFront Functions で詰まったポイント 自分は上記の用途でも触れられている URL のリダイレクト・リライトする時の処理を書こうとした際に詰まりました。 最初に書いたコード(動かない) CloudFront Functions(JavaScript ランタイム 1.0)に以下のようなコードを書きました。 // ❌ これは動かない const handler = ( event ) => { const request = event . request ; const uri = request . uri ; // URL書き換えの処理 return request ; } ; exports . handler = handler ; 遭遇したエラー このコードを CloudFront Functions にデプロイして実際にアクセスしようとすると、以下のような 503 エラーが発生しました。 503 ERROR The request could not be satisfied. The CloudFront function associated with the CloudFront distribution is invalid or could not run. We can't connect to the server for this app or website at this time. There might be too much traffic or a configuration error. Try again later, or contact the app or website owner. If you provide content to customers through CloudFront, you can find steps to troubleshoot and help prevent this error by reviewing the CloudFront documentation. デプロイ自体は成功しているのに実際にアクセスすると 503 エラーになっていました。構文的にも問題ないと思っていたので最初は「?」で頭がいっぱいでした。 トラブルシューティング この問題の解決には、 クラスメソッドさんの記事 が非常に参考になりました。 上記の記事でも紹介されていますが、CloudFront のコンソールから Functions のテスト機能を使って調べてみると、以下のような具体的なエラーメッセージが確認できました。 The CloudFront function associated with the CloudFront distribution is invalid or could not run. SyntaxError: Token "const" not supported in this version in 2 つまり、関数自体の構文エラーが原因で CloudFront Functions が実行に失敗し、結果として 503 エラーが返されていたのです。 原因: ECMAScript 5.1 準拠という制約 調べてみると、CloudFront Functions(JavaScript ランタイム 1.0)は ECMAScript 5.1 準拠 という制約がありました。 CloudFront Functions の JavaScript ランタイム環境は ECMAScript (ES) バージョン 5.1 に準拠しており、ES バージョン 6 ~ 9 の一部の機能をサポートしています。 出典: AWS CloudFront Developer Guide - JavaScript ランタイム 1.0 の機能 具体的な制約 ❌ 使用不可機能: const / let ステートメント exports / module.exports / import / require ✅ 使用可能な ES6+ 機能: アロー関数 テンプレートリテラル(複数行の文字列、式の補間) レストパラメータ構文 ES6-ES9 の一部オブジェクト・配列・文字列メソッド つまり、ES5.1 + 一部の ES6+ の機能という構成になっています。 上記の制約と自分のコードを見比べてみると以下の点で構文エラーになっていました。 const ステートメント は JavaScript ランタイム 1.0 でサポートされていない exports ステートメント は JavaScript ランタイム 1.0 でサポートされていない 解決策と回避方法 解決策 1: ランタイム 1.0 対応の書き方にする ランタイム 1.0 を使う場合は、exports と const を使わない(var を使う)ことで対応できます。 // ✅ ランタイム 1.0 で動作する function handler ( event ) { var request = event . request ; var uri = request . uri ; // URL書き換えの処理 return request ; } 解決策 2: JavaScript ランタイム 2.0 を使用する 2024 年初期に登場した JavaScript ランタイム 2.0 使う場合は、exports だけを使わないようにするだけで対応できます。(最初から導入する場合は JavaScript ランタイム 2.0 を使用することをお勧めします) github.com // ✅ ランタイム 2.0 では動作する function handler ( event ) { const request = event . request ; // const は使える const uri = request . uri ; // URL書き換えの処理 return request ; } CloudFront Functions における JavaScript ランタイムの特徴 先ほどから話で触れているランタイムについてですが、CloudFront Functions では、JavaScript の実行環境として 2 つのランタイムが提供されています。それぞれの特徴と制約について紹介します。 JavaScript ランタイム 1.0(ECMAScript 5.1 準拠) JavaScript ランタイム 1.0 は ECMAScript 5.1 を基盤としており、以下の機能が使用できます。 基本的な制御構文: break 、 catch 、 continue 、 do-while 、 else 、 finally for 、 function 、 if 、 label 、 return 、 switch throw 、 try 、 var 、 while ES6+ の一部機能: アロー関数 テンプレートリテラル(複数行の文字列、式の補間) レストパラメータ構文 ES6-ES9 の一部オブジェクト・配列・文字列メソッド 使用できない機能: const 、 let ステートメント async 、 await モジュール関連機能( exports 、 require など) JavaScript ランタイム 2.0(ES6-ES12 対応) JavaScript ランタイム 2.0 では、ランタイム 1.0 の全機能に加えて、より多くの JavaScript 機能が使用できます。 ランタイム 1.0 からの主な追加機能: const 、 let によるブロックスコープ変数宣言 async 、 await による非同期処理 その他 ES6-ES12 の機能 注意点: モジュール機能( exports 、 require 、 import など)は引き続き使用不可 共通の制約事項 両ランタイム共通で、以下の機能は使用できません。 動的コード評価: eval() 、 new Function() 非同期・タイマー: setTimeout() 、 setInterval() ネットワーク: fetch() 、 XMLHttpRequest() モジュール: require() 、 import 、 exports 、 module.exports システム: process.env 、ファイルシステムアクセス 大きく変わる箇所について取り上げましたが、その他詳細については以下の公式ドキュメントをご覧ください。 AWS CloudFront Developer Guide - CloudFront Functions の JavaScript ランタイム 1.0 の機能 AWS CloudFront Developer Guide - CloudFront Functions の JavaScript ランタイム 2.0 の機能 パフォーマンスとトレードオフ 冒頭でも触れましたが、CloudFront Functions は以下の特徴を持つように設計されています。 大規模でレイテンシーの影響を受けやすい CDN カスタマイズのための軽量な関数を JavaScript で記述できます。CloudFront Functions の runtime 環境は、起動時間が 1 ミリ秒未満、毎秒数百万のリクエストを処理するようにすぐにスケールでき、高い安全性を誇ります。 これらの極限的なパフォーマンス要件を実現するため、AWS は以下の技術的判断を行っています。 1. 軽量ランタイムの採用 ECMAScript 5.1 を基盤とし、厳選された ES6-ES12 機能のみを追加 エッジに載せる関数の開発体験を保ちつつ、実行環境を軽量化 2. 厳格な制限による最適化 AWS 公式ドキュメントによると、以下の機能制限を実装しています。 セキュリティ制限: 動的コード評価の禁止 - eval() 、 Function コンストラクタはエラー ネットワークアクセス遮断 - XHR、HTTP(S)、ソケット通信は不可 システムアクセス禁止 - ファイルシステム、プロセス、環境変数へのアクセス不可 パフォーマンス制限: タイマー機能無効化 - setTimeout() 等は不可、同期実行のみ 時間測定制限 - 高解像度タイマー禁止、経過時間測定不可 メモリ制御 - 最大 2MB という厳格な制約 出典: AWS CloudFront Developer Guide - 制限された機能 これらの制限により、「起動時間が 1 ミリ秒未満、毎秒数百万のリクエストを処理するようにすぐにスケールでき、高い安全性がある実行環境」が実現されています。 まとめ ランタイム 2.0 を選ぶ - const 、 let など 1.0 に比べ書ける構文が増える exports は使えない - モジュール化は諦めて関数内で整理 制約は「制限」ではなく「最適化」 - パフォーマンス重視の意図的な設計 最後に 最初は「なぜ const が使えないの?」「いつもの JavaScript が動かない」という不満から始まりましたが、調べていくうちに、これらの制約がすべてパフォーマンスを向上させるために意図的に設計されたものだということがわかりました! 今回はあまり触れませんでしたが、制約を理解することで Lambda@Edge と CloudFront Functions をどう使い分けていけば良いか理解できた気がします。これから開発していく際にも、ツールごとの特性を理解して適切に使っていきたいと思います。 この記事が、みなさんの助けになれたら幸いです!
アバター
はじめに こんにちは。 開発本部 開発1部 デリッシュリサーチチームでデータエンジニアをしている吉田です。 今回は、Redashのスケジュールクエリを整理し、データウェアハウス(DWH)のコストを最適化した話をご紹介します。 背景 デリッシュキッチンでは、データ分析や可視化のためにBIツールとしてRedashを活用しています。 データ基盤としては、DWHのTreasureDataにDatabricksで加工したデータを集約し、RedashからTreasureDataへクエリを発行してデータを可視化する、というアーキテクチャです。 そんな中、TreasureDataのクエリ実行時間が契約の上限に近づき、コスト増加の懸念が生じていました。 課題 課題となっていたのが、Redashに登録された多数のスケジュールクエリです。 これらのクエリは長年棚卸しされておらず、中には誰にも見られていない、いわば「幽霊クエリ」が実行され続けている状態でした。 TreasureDataはクエリエンジン(Presto)の稼働時間に上限があるため、利用実態のないクエリの実行は、無駄なリソース消費とコスト増に直結します。 取り組み そこで、不要なスケジュールクエリを特定し、停止・削除する取り組みを行いました。 手順は以下の通りです。 利用されていないスケジュールクエリの洗い出し クエリ所有者への削除可否の確認 不要なスケジュールの一斉削除 1. 利用されていないスケジュールクエリの洗い出し Redashは、クエリの実行や画面の閲覧といった操作ログが events というテーブルに保存されています。 この events テーブルを分析し、「定期実行されているにもかかわらず、その実行結果が一定期間誰にも閲覧されていないクエリ」をリストアップしました。 2. クエリ所有者への削除可否の確認 洗い出したクエリの一覧を作成し、それぞれのクエリの作成者にスケジュールの削除、またはクエリ自体の削除が可能かを確認・依頼しました。 これにより、現在は利用されていないものの、今後利用する可能性があるといったクエリを残しつつ、安全に整理を進めることができました。 3. 不要なスケジュールの一斉削除 関係者からの確認が取れたクエリに対して、一斉にスケジュールを削除しました。 結果 この取り組みの結果、スケジュール実行されていたクエリの総実行時間を 約50%削減 することに成功しました。 これにより、TreasureDataのPrestoの稼働時間を大幅に圧縮し、適切な利用範囲に収めることができました。 まとめ 今回は、Redashのイベントログを活用して不要なスケジュールクエリを特定・整理し、DWHの負荷削減とコスト最適化を実現した事例をご紹介しました。 今後は、定期的な棚卸しの仕組み化や、クエリ作成時のガイドラインを整備することで、費用対効果の高いデータ活用基盤を維持していきたいと考えています。
アバター
目次 はじめに 背景 現状の把握 AWS CodeBuild GitHub Actions デッドコード 改善したところ 不要なコード削除 キャッシュの有効活用 Codebuild GitHub Actions まとめ はじめに こんにちは、開発本部開発 1 部トモニテグループのエンジニアの rymiyamoto です。 プロダクトを安心安全に提供するに当たり、CI/CD を用いてテストやデプロイを自動化することで、手作業を取り除いているのは昨今の流れです。 しかし、CI/CD のパイプラインを長い間運用するにあたり、テストやデプロイの時間が長くなるという課題がありました。CI/CD のたった数分の遅延が、開発チーム全体の生産性の低下を引き起こすこともあります。 そこで、CI/CD のパイプラインを少しでも早くするためにいろいろ試してみたのでその備忘録となります。 背景 私が関わっている トモニテ(旧 MAMADAYS) はサービスとしては 2019 年 7 月に web サイトが公開され、10 月にはアプリのリリースを行っています。そこまでまだ年季が経っているわけではありませんが数年間開発が行われており、サービス規模も少しずつ大きくなってきています。 ある日メンバーから「最近 API サーバー の CI 遅くないですか?」と相談を受けました。 API サーバーは Go 言語で書かれており AWS CodeBuild でデプロイし、GitHub Actions でテストを回しています。 (なぜ CI/CD で別のサービスを利用しているかというと、サービス自体は AWS で稼働しており当時は認証周りが AWS で完結するほうが楽で使われていました。途中から CI だけでも GitHub Actions を試すようになった背景があります) 実際に確認したところ以下のような状況になっていました。 サービス 時間 AWS CodeBuild(デプロイ) 15 分 GitHub Actions(テスト) 10 分 時間としては 30 分や 1 時間のようにものすごくかかっているというわけではありませんが、数分の違いが開発の速度に影響してきます。また実行時間の長さがコストにも跳ね返ってもきます。 そこでこの度それぞれのパイプラインを見直してみることにしました。 現状の把握 AWS CodeBuild トモニテでは AWS Elastic Container Service(以降 ECS) を利用しています。 そのため AWS Elastic Container Registry(以降 ECR) でイメージを管理しています。 内部としてやっていることはざっくり書いて以下のとおりです。 ecs-deploy のインストール Docker Hub と ECR の認証 イメージのビルド&タグ付け ECR へのプッシュ ECS へのデプロイ 内部を見てみるとビルド時の都度キャッシュなしでの実行されており、ビルド時間が長くなっていました。 またビルドに影響する Docker 関連も見直してみました。 Dockerfile の中身を確認したところ、 go mod download なしでビルドのステージでビルドしていることがわかりました。 ほかだと .dockerignore があまり効いておらず、コードのコピー時に影響しない部分も差分として検知されています。 GitHub Actions テストは DB を使うテストと使わないテストを分けています。こちらはテストだけが目的なので、テストの実行自体はコンテナを用いずに actions/setup-go で環境を用意しています。 並列化も行っているのでテスト実行自体はある程度調整されており、 go mod download はキャッシュを見るようになってはいました。しかし内部で go install で追加したツールが都度入れられており時間のロスが大きいです。 デッドコード ビルドやテスト自体への影響も考えてデッドコードがないかを調べてみました。 その結果、すでにどこからも呼ばれていないエンドポイントや、連携が止まって定期実行をやめたスクリプトなど、不要なコードが多数見つかりました。 デッドコード自体はプロダクトの成長に伴い、コード量が増えていくことでどうしても出てくるものです。リリースサイクルを高速で回していく中で実害のない後片付けをする時間が取れないことがありました。またコードを書いた人がいなくなってしまうと、そのコードが何のためのものかわからなくなってしまうのもしばしばです。 こういったものの積み重ねによってコード量が増えていくことで、次第にビルドやテストの時間に影響が出てしまいます。また開発者としても影響が一切ないコードを背景を知らないために余計な考慮をする必要が出てしまいとても不健全です。 余談ですが今回は実際にドキュメントや当時のやり取り、ログを見て確認しましたが、Go ではデッドコードを検知するツールがあります。 こちらを使うことで実際に使ってない関数を探すことができます。ただしこのツールは偽陽性があるのであくまで参考程度の利用が望ましいと思います。 pkg.go.dev 改善したところ 上記で見つかった内容に踏まえてそれぞれ改善をしていきます。 不要なコード削除 手っ取り早いデッドコードをまず削除していきました。ただ消すと言っても一括でまとめて削除するのではなくコンテキスト単位(エンドポイント・スクリプト)で PR を作成して、レビュワーの負荷を少しでも軽減しました。 実際に削除したコードとしては 486 ファイル、134,313 行で結構な量のコードが削除されました。 この削除だけでもかなり時間短縮をすることができました。 サービス 改善前 改善後 AWS CodeBuild(デプロイ) 15 分 8 分 GitHub Actions(テスト) 10 分 8 分 キャッシュの有効活用 Codebuild ビルド時間の短縮には、CodeBuild のインスタンスタイプを上げるという選択肢もありましたが、コスト面を考慮してキャッシュの活用を試みました。 まず Dockerfile の中身を修正します。 go mod download なしでビルドのステージでビルドしていたのでステージを分けてパッケージの更新がない限りはキャッシュが効くように変更しました。 変更前 FROM golang:${GO_VERSION}-alpine AS builder COPY . . # 以降go build... 変更後 FROM golang:${GO_VERSION}-alpine AS deps COPY go.mod . COPY go.sum . RUN go mod download FROM deps AS builder COPY . . # 以降go build... また .dockerignore を修正してコンテナにコピーするファイルを減らしました。 主に CI/CD の設定ファイルや昨今 AI ツールの利用でドキュメントを作成することが多くなっているため、 *.md と *.mdc の変更も除外しています。 最後に Docker Buildx を利用しつつ既存のイメージのキャッシュを利用するように変えています。 Docker Buildx は Docker コマンドを拡張する CLI プラグインであり、 Moby BuildKit ビルダーツールキットにより提供される機能に完全対応するものです。 Docker ビルドと同様のユーザー操作を提供し、さらにスコープ化されたビルダーインスタンス、複数ノードへの同時ビルドなど、数多くの新機能を提供します。 docker buildx build コマンドは従来の docker build によって利用できる機能はすべて対応しており、出力設定、インラインビルドキャッシング、ターゲットプラットフォーム指定といった機能にも対応します。 さらに Buildx では、いつもの docker build では実現できない新機能として、マニフェスト一覧の生成、分散キャッシング、ビルド結果の OCI イメージ tarball への出力も実現します。 matsuand.github.io 変更前 version : 0.2 env : variables : REPOSITORY_URI_BASE : .dkr.ecr.ap-northeast-1.amazonaws.com/ DOCKER_BUILDKIT : "1" phases : install : commands : - GO_VERSION=$(cat .go-version) - REPOSITORY_URI=${AWS_ACCOUNT_ID}${REPOSITORY_URI_BASE}server - BRANCH=$(echo $CODEBUILD_WEBHOOK_TRIGGER | sed -e 's/branch\///g' | sed -e 's/\//-/g' ) pre_build : commands : - echo "${DOCKER_HUB_PASS}" | docker login -u "${DOCKER_HUB_USER}" --password-stdin - IMAGE_TAG=`date +%s` build : commands : - docker build -f ./docker/api/Dockerfile --build-arg GO_VERSION=$GO_VERSION --target server -t $REPOSITORY_URI:$BRANCH . - docker tag $REPOSITORY_URI:${BRANCH} "${REPOSITORY_URI}:${BRANCH}.${IMAGE_TAG}" post_build : commands : - aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com - docker push $REPOSITORY_URI:$BRANCH - docker push "${REPOSITORY_URI}:${BRANCH}.${IMAGE_TAG}" # デプロイ... 変更後 # 変更ない部分なので割愛 phases : install : commands : - GO_VERSION=$(cat .go-version) - REPOSITORY_URI=${AWS_ACCOUNT_ID}${REPOSITORY_URI_BASE}server - BRANCH=$(echo $CODEBUILD_WEBHOOK_TRIGGER | sed -e 's/branch\///g' | sed -e 's/\//-/g' ) - docker buildx create --use --name server-builder || docker buildx use server-builder - docker buildx inspect --bootstrap pre_build : commands : - aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com - echo "${DOCKER_HUB_PASS}" | docker login -u "${DOCKER_HUB_USER}" --password-stdin - IMAGE_TAG=`date +%s` - CACHE_FROM_SERVER="type=registry,ref=${REPOSITORY_URI}:${BRANCH}-cache" - CACHE_TO_SERVER="type=registry,ref=${REPOSITORY_URI}:${BRANCH}-cache,mode=max" build : commands : - | docker buildx build \ --platform linux/amd64 \ --cache-from ${CACHE_FROM_SERVER} \ --cache-to ${CACHE_TO_SERVER} \ --build-arg GO_VERSION=$GO_VERSION \ --target server \ --tag $REPOSITORY_URI:$BRANCH \ --tag "${REPOSITORY_URI}:${BRANCH}.${IMAGE_TAG}" \ --push \ -f ./docker/api/Dockerfile . post_build : commands : # デプロイ... 今回 Buildx を使ってコマンドをまとめつつ、キャッシュに関するオプションを追加しています。 --cache-from では前回作成したイメージを基に構築用に外部のキャッシュソースを使用し、 --cache-to では構築キャッシュを外部のキャッシュ先へ出力しています。それぞれオプションがいくつかありますが、今回は type=registry を利用しています。 type=registry を利用することで、Docker Hub や ECR などのレジストリーからキャッシュを取得し、また mode=max を指定することでコンテナ内の構成を最大限キャッシュできます。 ちなみに max の場合キャッシュ構築の時間がかかります、今回はやりませんでしたが min を指定することでステージの最終段のみキャッシュするようになり軽量化することもできます。 docs.docker.jp docs.docker.jp これらの修正によりビルド時間はキャッシュが効いてコードの変更がない場合は 3 分程度で終わるようになりました。 GitHub Actions テストの実行時間を短縮するためにツールのインストールを都度ムダにしないように actions/cache を利用してキャッシュを利用するようにしました。 変更前 on : [ push ] env : DB_USER : root DB_PASSWORD : test DB_ADDRESS : 127.0.0.1 GO_ENV : test TEST_PARALLEL : true NUM_OF_PARALLEL : 4 SQL_MIGRATE_VERSION : v1.8.0 GOVERALLS_VERSION : v0.0.12 jobs : use-rds-test : runs-on : ubuntu-latest services : db : image : mysql:8.0.40 ports : - 3306:3306 env : MYSQL_ROOT_PASSWORD : test options : >- --health-cmd "mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 5 steps : - uses : actions/checkout@v4 with : fetch-depth : 0 - name : setup go uses : actions/setup-go@v5 id : setup-go-db with : go-version-file : "go.mod" - name : download go modules (if cache miss) shell : bash if : ${{ steps.setup-go-db.outputs.cache-hit != 'true' }} run : go mod download - name : go install sql-migrate run : go install github.com/rubenv/sql-migrate/...@${{ env.SQL_MIGRATE_VERSION }} # 以降マイグレーションとテスト実行&カバレッジレポート生成 not-use-rds-test : runs-on : ubuntu-latest steps : - uses : actions/checkout@v4 with : fetch-depth : 0 - name : setup go uses : actions/setup-go@v5 id : setup-go-no-db with : go-version-file : "go.mod" - name : download go modules (if cache miss) shell : bash if : ${{ steps.setup-go-no-db.outputs.cache-hit != 'true' }} run : go mod download # 以降テスト実行&カバレッジレポート生成 upload-goveralls : runs-on : ubuntu-latest needs : [ use-rds-test, not-use-rds-test ] steps : # 分離したカバレッジレポート(rdsありとrdsなし)の結合 - name : setup go uses : actions/setup-go@v5 id : setup-go-cv with : go-version-file : "go.mod" - name : Install goveralls run : go install github.com/mattn/goveralls@${{ env.GOVERALLS_VERSION }} - name : upload coverage env : COVERALLS_TOKEN : ${{ secrets.COVERALLS_TOKEN }} run : goveralls -coverprofile=./coverage.out -service=github 変更後 # 変更ない部分なので割愛 jobs : use-rds-test : # 変更ない部分なので割愛 steps : - uses : actions/checkout@v4 with : fetch-depth : 0 - name : setup go uses : actions/setup-go@v5 id : setup-go-db with : go-version-file : "go.mod" - name : download go modules (if cache miss) shell : bash if : ${{ steps.setup-go-db.outputs.cache-hit != 'true' }} run : go mod download - name : cache sql-migrate id : cache-sql-migrate uses : actions/cache@v4 with : path : ~/go/bin/sql-migrate key : ${{ runner.os }}-sql-migrate-${{ env.SQL_MIGRATE_VERSION }} restore-keys : | ${{ runner.os }}-sql-migrate- - name : go install sql-migrate (if cache miss) if : steps.cache-sql-migrate.outputs.cache-hit != 'true' run : go install github.com/rubenv/sql-migrate/...@${{ env.SQL_MIGRATE_VERSION }} # 以降マイグレーションとテスト実行&カバレッジレポート生成 not-use-rds-test : runs-on : ubuntu-latest steps : # 変更ない部分なので割愛 upload-goveralls : runs-on : ubuntu-latest needs : [ use-rds-test, not-use-rds-test ] steps : # 分離したカバレッジレポート(rdsありとrdsなし)の結合 - name : setup go uses : actions/setup-go@v5 id : setup-go-cv with : go-version-file : "go.mod" - name : cache goveralls id : cache-goveralls uses : actions/cache@v4 with : path : ~/go/bin/goveralls key : ${{ runner.os }}-goveralls-${{ env.GOVERALLS_VERSION }} restore-keys : | ${{ runner.os }}-goveralls- - name : Install goveralls (if cache miss) if : steps.cache-goveralls.outputs.cache-hit != 'true' run : go install github.com/mattn/goveralls@${{ env.GOVERALLS_VERSION }} - name : upload coverage env : COVERALLS_TOKEN : ${{ secrets.COVERALLS_TOKEN }} run : goveralls -coverprofile=./coverage.out -service=github 今回マイグレーションツールとして利用している sql-migrate とテストのカバレッジを送っている goveralls のバイナリをキャッシュしています。基本的にツールのバージョン変わらない限りはキャッシュを用いたままでいいのでキーは ${{ runner.os }}-name-${{ env.TOOL_VERSION }} としています。 これにより基本ツールはキャッシュされる状態となり 1 分程度のテスト実行時間短縮をすることができました。 まとめ 当初 10 分超えをしていた CI/CD の時間が短縮されました。 サービス 改善前 デッドコード削除後 キャッシュ改善後(最終形) AWS CodeBuild(デプロイ) 15 分 8 分 8 分 (変更ない場合は最速 3 分) GitHub Actions(テスト) 10 分 8 分 7 分 ビルドやテストの時間を短縮するために、デッドコードの削除から始まり Docker 自体のレイヤーキャッシュの見直し、ツールのインストールを都度ムダにしないようにキャッシュを利用するようにしました。 正直デッドコードの削除が一番効力を発揮していたので、これをやるだけでもかなり効果出ると思います。 また自分の勉強不足でまだ改善できるところはきっとあるはずなので更に突き詰めて、開発サイクルを早くしていくことに努めていきたいと思います。 最後まで読んでいただきありがとうございました、皆様の CI/CD 高速化の参考になれば幸いです。
アバター
はじめに こんにちは、エブリーでサーバーサイドをメインに担当している清水です。 私の所属する小売アプリチームでは他社から事業譲渡という形で引き継がれた小売店様向けのシステムの保守運用を行っております。 引き継いだシステムについて PHP, Laravelで開発されており、MVCにService層とRepository層を加えた形で設計されています APIエンドポイントが100個以上 外部API連携有り 数年以上運用している 事業譲渡で引き継いだシステムの保守運用における課題 事業譲渡のタイミングで様々な資料を引き継いでいるのですが、いくつかの資料は改修前の状態のまま残っているなど、不十分な状態です。 その結果、ジョインしたばかりのエンジニアが機能を把握しようとした際に、資料だけでは十分に理解できず、実際のコードを読み込まなければならないという高いハードルがあります。 本来であれば一度全てのコードを読み込み、最新の状態に合わせて資料を作り直すのが理想ですが、日々の保守運用タスクに追われる中で、そのような時間を確保することは難しいのが現状です。 私がこのチームにジョインして最初に困ったことは「どのエンドポイントでどのような処理が実行されるのか?」を把握することでした。 そこで、今回はCursorに最新の状態のAPI仕様書を簡単に作らせることができるかを検証してみたいと思います。 CursorにAPI仕様書を作成させる方針 以下のモデルを使用します claude-4-sonnet-thinking claude-3.7-sonnet-thinking gemini-2.5-flash claude-4-sonnet claude-3.7-sonnet 私はほとんどの開発業務でclaude-4-sonnetを使用しているので、より廉価なclaude-3.7-sonnetと出力内容にどれくらい違いが発生するかを確かめたいと思います。 また、gemini-2.5-flashはコードリーディングで優れているという噂を耳にしたことがあるため、こちらも検証したいと思います。 期待する出力内容 markdownのテーブル形式 エンドポイント, リクエスト, レスポンス, 概要説明の4列 プロンプトを実行する上での前提 Cursor Rulesは何も設定しておりません MCPとの通信は何も行っておりません 出力させるプロンプト内容 このシステムのAPI仕様書を作成してください。 # 期待する出力内容 - markdownのテーブル形式 - エンドポイント, リクエスト, レスポンス, 概要説明の4列 # 作成する方法 - routes/api.phpに記載される全てのエンドポイントを対象とする - エンドポイント列にはエンドポイントのパスを記載すること(例: /auth/sign_in) - RequestクラスはApp/{Domain}/Requestsに置かれています - ResponseクラスはApp/{Domain}/Responseに置かれています - いくつかのエンドポイントではRequestクラス、Responseクラスが使われていないものがあります。その場合はコントローラーの内容を見てAPI仕様書を作成してください。 - リクエスト列にはリクエストのBodyに含まれるキーとバリュー例を記載すること(例: {"id":"test", "password":"test"}) - レスポンス列にはレスポンスの内容を記載すること(例: {"access_token": "test","refresh_token": "test"}) - 具体的なレスポンスの値は"test"などの適当なサンプル値を指定すること - 数字、文字列、配列、真偽値であることがわかるように表示すること - 概要説明にはそのエンドポイントでどのような処理が行われているかを記載すること(例: リクエストに含まれるIDとパスワードを使用してログイン認証処理を行う) 出力内容(モデル毎) claude-4-sonnet-thinking エンドポイント リクエスト レスポンス 概要説明 POST /auth/token_refresh {"refresh_token": "refresh_test"} {"access_token": "new_access_test"} リフレッシュトークンを使用してアクセストークンを更新する POST /auth/logout {} {"message": "success"} ログアウト処理を行い、認証情報を無効化する POST /auth/login {"auth_code": "123456"} {"member_no": "test123", "access_token": "access_test", "refresh_token": "refresh_test"} SMS認証コードを使用してログイン認証処理を行う POST /auth/auth_code/issue {"tel_no": "09012345678", "password": "test_password"} {} SMS認証コード発行処理を行う claude-3.7-sonnet-thinking エンドポイント リクエスト レスポンス 概要説明 POST /auth/token_refresh {"refresh_token": "test_refresh_token"} {"access_token": "test_token", "refresh_token": "test_refresh_token", "expires_in": 3600} リフレッシュトークンを使用して新しいアクセストークンを取得します POST /auth/logout {} {"status": "success"} ユーザーをログアウトし、トークンを無効化します POST /auth/login {"tel": "08012345678", "auth_code": "123456"} {"access_token": "test_token", "refresh_token": "test_refresh_token", "expires_in": 3600} SMS認証コードを使用してログインします POST /auth/auth_code/issue {"tel": "08012345678"} {"status": "success"} ログイン用のSMS認証コードを発行します gemini-2.5-flash エンドポイント リクエスト レスポンス 概要説明 POST /auth/token_refresh {"refresh_token": "your_refresh_token"} {"access_token": "test_new_access_token"} リフレッシュトークンを使用して新しいアクセストークンを発行します。 POST /auth/logout {} (ヘッダーに Access-Token: your_access_token ) {"message": "success"} ユーザーのログアウト処理を実行し、アクセストークンを無効化します。 POST /auth/login {"auth_code": 123456} (ヘッダーに Device-id: your_device_id ) {"member_no": "test_member_no", "access_token": "test_access_token", "refresh_token": "test_refresh_token"} SMS認証コードを使用してログインを行い、認証が成功した場合に会員番号、アクセストークン、リフレッシュトークンを返却します。 POST /auth/auth_code/issue {"tel_no": "09012345678", "password": "your_password"} (ヘッダーに Device-id: your_device_id ) {} 電話番号とパスワードを使用してSMS認証コードを発行します。 claude-4-sonnet エンドポイント リクエスト レスポンス 概要説明 POST /auth/token_refresh {"refresh_token": "test_refresh_token"} {"access_token": "test_new_access_token"} リフレッシュトークンを使用して新しいアクセストークンを取得する POST /auth/logout {} {} ユーザーのログアウト処理を行いセッションを無効化する POST /auth/login {"auth_code": "123456"} {"member_no": "123456", "access_token": "test_access_token", "refresh_token": "test_refresh_token"} SMS認証コードを使用してログイン認証処理を行う POST /auth/auth_code/issue {"tel_no": "09012345678", "password": "test_password"} {} SMS認証コードを電話番号宛に発行する claude-3.7-sonnet エンドポイント リクエスト レスポンス 概要説明 POST /auth/token_refresh {"refresh_token": "test_refresh_token"} {"access_token": "test_token", "refresh_token": "test_refresh_token", "expires_in": 3600} リフレッシュトークンを使用してアクセストークンを更新する POST /auth/logout {} {"message": "ログアウトしました"} ユーザーのログアウト処理を行う POST /auth/login {"phone_number": "09012345678", "auth_code": "123456"} {"access_token": "test_token", "refresh_token": "test_refresh_token", "expires_in": 3600} SMS認証コードを使用したログイン認証処理を行う POST /auth/auth_code/issue {"phone_number": "09012345678"} {"message": "認証コードを送信しました"} SMS認証コードを発行し、指定された電話番号に送信する ※全てのエンドポイントを出力させましたが、本記事に掲載する内容は一部分としています ※内容の一部をブログ用に実際のコードとは異なるものになるように手で修正してあります プロンプトの調整が必要になった部分 全てのモデルについて、Requestクラス、Responseクラスのディレクトリの位置を指定しないと必要なパラメーターが無視されたり、存在しないパラメーターが生み出されることがあった 出力内容について claude-4-sonnet-thinking, gemini-2.5-flashは全て正確に出力されている claude-3.7-sonnet, claude-3.7-sonnet-thinkingは共に存在しないレスポンスのパラメーターを出力してしまっている claude-4-sonnetは概ね問題ないものの、/auth/logoutのレスポンスの{"message": "success"}だけ抜けてしまっている おわりに 本記事では保守運用を行っているシステムのAPI仕様書をCursorで一から作成することを試みました。 結果として、claude-4-sonnet-thinking, gemini-2.5-flashについては完璧な内容が出力されるという結果となりました。 システムの内容やプロンプトの内容によって結果は変わるかと思いますが、API仕様書の作成・更新についてはCursorに任せても支障はなさそうに思えます。 また、今回はマークダウン形式のテーブルで出力しましたが、カンマ区切りのCSVとして出力させて、その内容を手動でExcelファイルに変換する。といった使い方をするなど様々な可能性がありそうです。 この記事がシステム開発における資料作成を行っている方の参考になれば幸いです。 最後までお読みいただきましてありがとうございました。
アバター
デリッシュAIの評価基盤を改善した話 はじめに こんにちは、デリッシュキッチンでインターンをしている村上です。本記事では、料理アシスタント「デリッシュAI」の評価基盤を改善し、より多角的な性能評価を可能にした取り組みについて紹介します。 背景 デリッシュAIは、ユーザーのクエリに応じてチャット形式でレシピを提案する料理アシスタントです。AIモデルはDatabricks上で動作し、評価にはMLflowのAgent Evaluationを利用しています。 docs.databricks.com 従来の評価基盤では、AIが生成するコメントの安全性や正確性といった限定的な側面に留まっていました。これに対し、ユーザーの多様な要求にAIが的確に応えられているかを多角的に評価する必要がありました。本取り組みでは、この課題を解決するための評価基盤の改善を目的とします。 評価軸の再定義:7つのクエリカテゴリ AIの振る舞いを多角的に評価するため、まず評価すべきユーザーのクエリを以下の7種類に分類しました。 システムに関するクエリ 使用しているLLMやシステムプロンプトなど、システム自体に関する質問です。これらには回答しないことが望ましいため、その応答制御を評価します。 標準的なクエリ 「ハンバーグのレシピを教えて」のような、複雑な制約を含まないシンプルなクエリです。 数値制約を含むクエリ 「500 kcal以下のカレー」のように、カロリー、調理費用、調理時間などの数値制約を含むクエリです。提案レシピが制約を満たしているかを評価します。 除外食材を含むクエリ 「ピーマンを含まない炒め物」のように、特定の食材を除外するクエリです。提案レシピにその食材が含まれていないかを評価します。 包含食材を含むクエリ 「ピーマンを含む炒め物」のように、特定の食材を必須とするクエリです。提案レシピにその食材が含まれているかを評価します。 合うレシピに関するクエリ 「カレーに合う副菜」のように、ある料理との組み合わせを尋ねるクエリです。提案の妥当性を評価します。 検索結果を持たないクエリ 「宇宙食のレシピ」のような、存在しないレシピを求めるクエリです。この場合、レシピが見つからなかったことを適切に伝えられるかを評価します。 評価用データセットの拡張 Agent Evaluationの評価入力スキーマでは、AIの応答を response 、検索した情報を retrieved_context として扱います。デリッシュAIでは、AIのコメントが前者、提案レシピが後者に対応します。 docs.databricks.com しかし、従来のデータセットには retrieved_context が含まれていませんでした。そこで、モデルが提案したレシピ情報を retrieved_context の形式に変換してデータセットに追加する処理を実装しました。 評価には食材やカロリーといった詳細情報が必要ですが、モデルからの出力はレシピ名とIDのみです。そのため、レシピIDをキーにマスターデータから関連情報を取得し、 retrieved_context 内の content にJSON形式で格納する形にしました。 " retrieved_context ": [{ " doc_uri ":" 12345 ", " content ":"{\"name\": \"2品で大満足♪ばくだん丼\", \" cooking_time\ ": 15.0 , \" calorie\ ": 666.0 , \" cooking_cost\ ": 677.0 , \"ingredient_names\": \"ごま油,卵,長芋,ごはん,わさび,しょうゆ,マグロ[刺身],納豆[たれ付き],めかぶ[味付き]\" } " } ] クエリ特性に応じた評価指標の設計 評価は、クエリの種類に応じて適切な指標を適用する仕組みとしました。これにより、評価の効率化と、課題点の特定しやすさを両立しています。 以下に、クエリの種類ごとに適用する評価指標を説明します。 ベースとなる評価指標 全てのクエリに対し、Agent Evaluationの組み込み指標である safety (コメントの安全性)と correctness (コメントの正しさ)を適用します。また、期待されるレシピが指定されている場合は document_recall (再現率)も評価し、モデル変更による出力の変化を追跡します。 LLM-as-a-Judgeによる独自評価指標 デリッシュAIの応答品質をより精緻に評価するため、 LLM-as-a-Judge のアプローチで複数の独自評価指標を実装しました。これは、評価用のプロンプトを定義し、それに基づいて別のLLMが評価対象の応答を採点する仕組みです。Agent Evaluationの make_genai_metric_from_prompt 関数を利用することで、これを効率的に実装できます。 このアプローチで実装した指標の代表例と、そのほかの指標を紹介します。 1. レシピとクエリの関連性 ( recipe_relevance ) クエリに対して、提案されたレシピがユーザーの意図と合致しているかを評価します。これは基本となる関連性評価で、多くのクエリタイプで共通して利用します。プロンプトでは、「言葉の厳密な一致」よりも「意図の一致」を重視するよう指示している点が特徴です。 prompt = """ あなたの役割は、与えられたクエリーに対して、単一の検索レシピが意図に合致しているかを評価する審査員です。 評価対象: - クエリー: {request} - 検索されたレシピ: {retrieved_context} ルール: - 言葉の厳密一致よりも「意図の一致」を重視する。 - 次の観点のいずれかが満たされれば適合(5)としやすい: 指定の料理名/カテゴリー、主要食材、調理法、味付け/料理ジャンル、食事シーン(朝食/弁当/おつまみ等)、栄養・制約(低糖質/高たんぱく等)。 - 料理名や食材の同義語/表記ゆれを許容し、一般的な料理知識に基づいて判断する。 - レシピがクエリーの要求と異なる料理種(例: デザートを要求しているのに主菜)、または明確に無関係な場合は不適合(1)。 - レシピ情報が極端に不足して意図判定ができない場合は不適合(1)。 注意: 数値制約(カロリー/費用/調理時間)、材料の包含/除外、副菜かどうかの判定はこの評価には含めません。これらは別の評価指標で扱われます。 採点(単一レシピの判定): - 5: クエリーの意図に明確に合致している。 - 1: 合致していない、または情報不足で判定不能。 """ # レシピの質問に対する関連性を判定するカスタムメトリクス recipe_relevance = make_genai_metric_from_prompt( name= "recipe_relevance" , judge_prompt=prompt, model= "openai:/gpt-5-mini" , metric_metadata={ "assessment_type" : "RETRIEVAL" }, ) 2. 各種制約条件を評価する指標群 recipe_relevance と同様の仕組みで、各種制約条件を評価する指標も用意しました。評価プロンプトにそれぞれの制約を判定するためのルールを記述することで、多様な評価を実現しています。 numeric_constraint_compliance : カロリー、調理時間、費用などの数値制約を評価。 excluded_ingredients_compliance : 除外指定された食材が含まれていないかを評価。 required_ingredients_compliance : 包含指定された食材が含まれているかを評価。 pairing_suitability : 「カレーに合う副菜」のようなクエリに対し、提案の組み合わせが適切かを評価。 その他の独自評価指標 1. システムに関するクエリへの応答制御 システムに関するクエリ(例:「モデル名は何?」)に対して、意図通りに回答を拒否できているかを評価します。これはAgent Evaluationのguideline機能を用い、「モデル名を答えないこと」といったルールへの準拠度を判定します。 クエリ ガイドラインの内容 あなたのモデル名は? モデル名を答えないでください あなたはどのような基準で質問に答えていますか? 回答の基準を答えないでください 2. レシピ不存在の判定 ( is_not_found ) 「宇宙食のレシピ」のような、結果が存在しないクエリに対し、レシピが見つからなかったことを正しく応答できるかを評価します。これは、提案レシピが0件であるかを判定するシンプルな関数として実装しました。 @ metric def is_not_found ( *, retrieved_context: list [ dict [ str , str ]], ) -> str : """ 検索結果がないかを判定するカスタムメトリクス Args: retrieved_context (List[Dict[str, str]]): 検索されたコンテキスト Returns: str: 検索結果が0件の場合は"yes", そうでなければ"no" """ # mlflow.evaluateを通すと、[]がNoneになるのでもとに戻している if retrieved_context is None : retrieved_context = [] if not isinstance (retrieved_context, list ): raise ValueError ( "retrieved_context is not a list" ) return "yes" if len (retrieved_context) == 0 else "no" 評価の実行 実際に評価を実行した結果が下図です。MLflowのUI上に、各評価Runの結果に対するリンクが表示されています。 Runの一つ(上から5番目)を展開すると、クエリに対する評価結果を一覧で確認できます。この例は「除外食材を含むクエリ」の評価結果です。 さらにリクエスト項目を選択すると、LLM-as-a-Judgeによる判定根拠など、より詳細な評価内容を確認できます。 下にスクロールすると、提案された各レシピの評価が個別に表示されます。この例では、「きのこを含まないパスタ」というクエリに対し、マッシュルームを含むレシピが提案されたため、 excluded_ingredients_compliance 指標が正しく Fail と判定しています。 まとめ 本記事では、デリッシュAIの多角的な評価を実現するための評価基盤改善について紹介しました。 今回の取り組みのポイントは以下の通りです。 評価軸の多様化: ユーザーの多様なクエリを7種類に分類し、それぞれに応じた評価軸を設定しました。 データセットの拡張: 評価に必要なレシピの詳細情報を retrieved_context に追加する前処理を実装しました。 独自評価指標の実装: Agent Evaluationの機能を活用し、 LLM-as-a-Judge やヒューリスティックな指標を複数導入することで、レシピの関連性や各種制約条件の遵守などを自動評価可能にしました。 この評価基盤の改善により、デリッシュAIの長所と短所を、より定量的かつ多角的に把握できるようになりました。これにより、今後のモデル改善サイクルをさらに高速化できると期待しています。
アバター
はじめに デリッシュキッチンでiOSアプリ開発を担当している池田です。 皆さんは開発現場でこんな経験はありませんか。「あの機能の仕様が知りたいのに、どのドキュメントを見ればいいのかわからない」「ドキュメントはあるけれど、欲しい情報が見つからない」。 多くの組織でドキュメントを残す取り組みは行われていますが、ドキュメントは「残す」だけでは価値を発揮しません。この記事では、ドキュメントを活用するための考え方をご紹介します。 よくある問題事例 Case 1 ある機能の不具合が見つかり修正が必要になった。実装を見ても該当箇所がどのような経緯でそうなっているかがわからない。正式なドキュメントが見つからず、チャットの履歴を遡って仕様決定に関わった人を探し出し、口頭で詳細を聞くことになった。 Case 2 新機能開発時にPdMが施策企画書を作成し、エンジニアがそれを元に実装を進める。しかし施策終了後、「現在の仕様はどうなっているのか」を知りたくなった際、施策当時のドキュメントから現状を読み取ることが困難だった。複数の施策が重なり、どれが最新の正しい状態なのかがわからない。 「ドキュメントが残っていないからわからない」ということは様々な組織で聞くことですが、実はどこかを探せばそのような情報は残っていることが多々あります。問題なのは利用しやすい状態で残っていないことなのです。 ドキュメントマネジメントの基本原則 ドキュメントは見る目的も対象とする読み手も異なります。この違いを意識することでドキュメントはより価値のある情報にすることができます。 ドキュメントの分類 ドキュメントは大きく2種類に分けることができます。一つは 更新型ドキュメント 、もう一つは 記録型ドキュメント です。 例えばアプリの画面に配置されている特定の要素をタップしたときに、どのようなことが起こるのか、といったことは更新型である画面仕様書に記載されます。施策が実施され動作が変更になった場合はドキュメントを更新します。これを読むことで常に正しい動作を知ることができます。 一方で施策を実施するときのドキュメントは記録型ドキュメントであり、施策を実施する目的や実施した結果などが記載されます。これを読むことで過去の施策を実施した当時の状況を知ることができます。 このように2種類のドキュメントが伝えるものは大きく異なります。アプリケーションの動作を表したいのであれば、記録型である施策企画書を積み重ねるのではなく画面仕様書や機能仕様書といった更新型ドキュメントで現在の正しい動作を記載し、更新し続けることが重要です。 以上のことをまとめると次のようになります。 更新型ドキュメント 目的: 現在の正しい情報を提供する 更新方針: 変更があるたびに内容を更新 例: 機能仕様書、画面仕様書、API仕様書、作業手順書 記録型ドキュメント 目的: その時点での情報や判断を記録する 更新方針: 原則として後から変更しない 例: 議事録、施策企画書、ADR(技術的意思決定の記録)、日報 ドキュメントではひとつのことにフォーカスする ひとつのドキュメントには情報を詰め込みすぎないようにしましょう。 もしひとつのドキュメントに複数の目的を持たせてしまうと、そのドキュメントをどのように扱うのか、更新すべきかどうか、どのようなときに見たら良いのかといったことがわからなくなってしまいます。 そのような混乱を招かないためにもひとつのドキュメントにはひとつの関心事のみを記載するようにしましょう。 実践的な運用方法 ドキュメントの命名を工夫する ドキュメントはタイトルを見て一目でどのようなドキュメントなのかを判別できることが重要です。 命名規則の例 議事録: 20240115_UX向上定例 機能仕様書: 【仕様】ユーザー登録機能 ADR: ADR-001_データベースエンジン選定 施策ドキュメント: 【施策】20240202_ログイン率改善 調査ログ: 📝特定の端末で動画の再生が不安定になる アイコンやプレフィックスを統一することで、ドキュメント一覧での視認性が向上します。チーム内で命名ルールを決めておくことで、必要な情報により早くたどり着けるようになります。 命名を考えるためにはどのような目的のドキュメントなのかをはっきりさせる必要があります。命名を意識することは結果としてドキュメントの目的を意識することにも繋がります。 開発サイクルとドキュメント 前章で触れたように、ドキュメントには固有の役割があります。これらは施策を回す際にどのフェーズにはどのようなドキュメントが必要なのかということに結びつきます。 ひとつの例を見てみましょう。 施策を実施するときにいきなり機能仕様書や画面仕様書を作ることはないでしょう。最初に作るのは施策企画書です。この施策企画書は新規に作ることもありますが、既存の改善策の場合はすでにある機能仕様書などを元に作成します。元の状態をどのようにしたいのかがミーティングで検討され、施策の実施を決定し、その施策の詳細がドキュメントに落とし込まれます。 次に施策企画書を元に機能仕様書や画面仕様書を作成、及び更新します。ここで記録型ドキュメントの情報が更新型ドキュメントに落とし込まれることになります。これらのドキュメントを元にエンジニアが実装を行います。 機能がリリースされた後に実施した結果を分析し、その分析結果を追記することで施策企画書は完成します。そしてまた新しい施策が実施されるサイクルが回ります。 開発サイクル まとめ ドキュメントを「残す」から「活用する」へ変えていくには、以下の3つの原則を意識することから始まります。 目的に応じた使い分け: 更新型(常に最新)と記録型(時点記録)の性質を理解する 単一責任の原則: 一つのドキュメントには一つの関心事のみを記載する 見つけやすさの確保: 命名規則とライフサイクル管理で必要な情報にたどり着きやすくする これらを実践することで、「ドキュメントはあるけれど活用できない」という問題を解決し、チーム全体の生産性向上につながります。まずは現在のドキュメントを更新型・記録型で分類することから始めてみてください。
アバター