0から作る個人開発アプリケーション:サーバーサイド実装編その2(API実装)

はじめに

moai510.hatenablog.com

前回こちらで書いた内容の続きです。HASURAのRemoteSchemaとして登録するために、ここではGraphQL APIの形で実装します。

なお、今回の実装差分はこちらのブランチで作業しています。

GitHub - tonouchi510/application-arch-blueprint at work/backend-service-2 · GitHub

実装

GraphQL API実装には gqlgen というライブラリを使います。

ロジック部分は大体前回書けたので、今回やることは以下です。

  • GraphQL APIのスキーマを定義する
  • APIの中身を実装する
  • APIリクエストを認証する
  • サーバーの起動スクリプトを書く

GraphQL APIのスキーマを定義する

schema.graphqlsというファイルに、APIやモデルの定義を書きます。今回のアプリの参考実装は以下に置いてあります。

https://github.com/tonouchi510/application-arch-blueprint/blob/work/backend-service-2/backend/circle-service/internal/interface/graph/schema.graphqls

なお、GraphQLでは更新系APIをmutation、参照系APIをqueryと呼び、それぞれ別々に定義します。

スキーマを定義したら、gqlgenを使ってGoのコードを自動生成します。gqlgenは公式リポジトリのREADMEを参考にセットアップしてください。

# スキーマファイルからの自動生成。なお、gqlgen init時に生成されるyamlファイルは以下指定の場所に配置しています。
$ go run github.com/99designs/gqlgen generate --config configs/graphql/gqlgen.yml

モデルやAPIの雛形のコードが自動生成されているので、これを使って開発します。

APIの中身実装

APIの中身については、すでに実装済みのアプリケーションサービスを呼び出すだけで基本的に完結します。 それ以外は入出力を決められたフォーマットに修正するくらいです。

具体的な実装は、自動生成されたschema.resolvers.goの中で定義された各関数の中身を実装していく形になります。

アプリケーションサービスを使うので、その依存注入も必要ですが、これも自動生成されたresolver.goに以下のような感じで追加して正しくDIすれば、schema.resolvers.goで使えるようになります。

type Resolver struct {
    userApp       appusers.IUserApplicationService
    circleApp     appcircles.ICircleApplicationService
    boardApp      appboards.IBoardApplicationService
    permissionApp apppermissions.ICirclePermissionApplicationService
}

APIの実装例の一つを記載しておくと以下のような感じです。

func (r *mutationResolver) AddCircleMember(ctx context.Context, input model.AddCircleMemberInput) (bool, error) {
    if err := r.circleApp.AddMember(ctx, appcircles.CircleAddMemberCommand{
        CircleId:    input.CircleID.String(),
        NewMemberId: input.NewMemberID,
    }); err != nil {
        return false, err
    }
    return true, nil
}

APIリクエストを認証する

今回実装するGraphQL APIは、こちらの記事で述べたHASURAにRemote Schemaとして登録します。HASURAはAPI Gatewayにあたり、ユーザーからのリクエストは全てここを経由するようにしています。

アーキテクチャ図再掲

そのため、APIリクエストの認証(JWT検証)自体はHASURA側で行われています。HASURAはAPIを受け取ったら、AuthorizationヘッダーをFirebase Authに問い合わせて検証し、中身のユーザーIDやclaims(Roleなど)を取り出してアクセス制御を行います。

ここで、RemoteSchemaとして登録したAPIには、取り出したユーザー情報をヘッダーに追加した上でフォワーディングしてくれるため、実はバックエンドサービスのAPIでは認証は不要でそれを取り出してコンテキストに保存するくらいでOKです。

※もちろんバックエンドサービスをインターネット公開したり、HASURA以外の経路を用意する場合は別ですが

具体的な実装は以下を参照してください。

https://github.com/tonouchi510/application-arch-blueprint/blob/work/backend-service-2/backend/circle-service/internal/interface/graph/middleware/middleware.go

サーバーの起動スクリプトを書く

ここでは、環境変数などからDBとの接続やサーバーのセットアップを行い、GraphQL APIをハンドリングしたサーバーを起動します。

先ほどのミドルウェアも設定します。

https://github.com/tonouchi510/application-arch-blueprint/blob/work/backend-service-2/backend/circle-service/cmd/server/main.go

まとめ

今回はAPI部分の実装の流れを参考までに書いてみました。ここは特にロジックを書く部分ではないため、サーバーのセットアップなどが終わったら、各APIはアプリケーションサービスを叩くくらいです。

コード例もあくまで参考程度に考えてください。

個人開発アプリをリリースした話:将棋Lab

リリースしたアプリ

note.com

去年の11月頃になりますが、こちらのアプリをリリースしました。せっかくなのでこちらでも宣伝させていただきます。

将棋の勉強・研究用のアプリで、特許にも出願中の類似局面検索機能とそれに関連したパーソナライズ分析機能が目玉となるアプリとなっています。 興味ある方はブログやWebサイトを覗いてもらえればと思います。

PRを打っていないにも関わらず、リリース初日で登録者が100人を超えることができ、当時自分としても驚いたとともに作ってよかったと感じれました。

上記リリースブログの方も、気づいたらいいねが100を超えており、興味を持ってもらえていることを感じました。 あくまで趣味の時間で取り組んでいる個人開発なので、中々まとまった時間を取れないのですが、今後も細々と機能改修も頑張っていくつもりです。

技術構成・アーキテクチャの紹介

基本的には、こちらのシリーズでも使っているような構成・技術で開発しています。

moai510.hatenablog.com

今回のリリースしたアプリは、対象とする市場規模的に、あまり収益化できるようなものではないですが、ひとまず一発目として個人開発アプリをリリースまで持っていけて良かったなと思います。

シリーズ中でも述べましたが、一度アプリケーション開発の大枠を整えておけば、別のアプリを作る際もかなり効率が上がるので、そういった意味で今後も色々出していければと考えています。

アーキテクチャ

雑な図なので雰囲気ですが、大体以下の図のような構成です。

  • HASURA:api-gatewayとして、バックエンドサービスやDBを仲介
  • shogi-service:主要機能を提供するサービス
  • game-service:対局機能を提供するサービス
    • WebSocket + Redis pub/sub
    • MemoryStore(Redis)で対局状態管理
    • CloudSQLで対局結果、棋譜の永続化
  • payment-service:stripeの決済管理
  • その他コンポーネント

個人開発アプリにしては複雑な構成になっていますが、それぞれ責務やリソース要件が変わる部分なので分離しています。特に、ユーザー管理やpayment-service(決済・サブスク)なんかは他のアプリ開発でも使いまわせるものです。その意味でもマイクロサービス化しています。

また、サービス内部は基本Goで書かれており、DDDの設計思想で作られています。

最近リリースした対局機能について

将棋Labでは最近対局機能も自前で持たせることにしました。内容についてはこちらに記載してあります。

note.com

このブログでは、対局機能を実現する上での技術的な部分について、いくつかかいつまんで書いてみます。

通信インターフェース

まず、クライアント向けのインターフェースですが、対局機能ではプレゼンスチェック(online/offline)や指し手の送受信、対局状態通知などで、頻繁にサーバー/クライアント間で通信が発生します。そのため、WebSocketのコネクションを貼って各種メッセージをやり取りすることにしています。

この段階で、statelessなAPIからstatefulなAPIになるため、色々と扱いの難しい問題が生じてきます。

例えば、ネットワークの一時的な切断などによって、メッセージを取りこぼした場合の対応や、再接続による状態復元など、いくつか考えなければいけないことが出てきます。エラーハンドリングや再試行、切断処理も適切なケアが必要です。

クライアント/サーバー間のみに閉じたアプリケーションであればそこまで考慮点は少ないですが、対局機能は2クライアント間で対局状態に同期が取れている必要がありますし、対局時間管理も正しく行わなければいけない点もあります。

※この辺りの設計は色々考えられますが、最低限の構成でコスト削減したかったため、基本的に対局関連の処理は両ユーザーのWeb Socket上で呼び出すようにしています。

※接続切れを考慮して別のmaster worker的なものを用意するのもありです

対局状態の保存

こちらはRedisを使用します。理由はいくつかありますが、いかに列挙しておきます。

  • 対局状態は高頻度に更新・取得されるもので、低レイテンシが求められる
  • 永続化する必要がない(その必要があるデータはRDBに保存)
  • pub/sub機能がある
    • 対局ユーザー間に指し手や対局状況を配信するために使える
  • game-serviceはスケールアウトするので、対局ユーザーが必ず同一インスタンスに接続するとは限らない
    • 単一のデータソースが必要

他にも、ノード間では時刻のズレも生じる可能性もあるため、Redisの時刻(Redis time)を正として利用します。

ネットワーク遅延・切断への対処

ネットワークの不具合等によって、たまたまメッセージやRedis pub/subを取りこぼす可能性があり、特に指し手受信を取りこぼして放置するとタイムアップしてしまいます。

他にも切断や再接続など、さまざまなパターンのエラーケースに対処しなければいけません。

  • タイムアップギリギリの指し手の扱い
    • => 例えばタイムアップ時間にバッファを持たせるなど
  • 片方がネットワーク切断された場合
    • 再接続 => Redisから状態復元
    • 再接続待機時間切れ => 接続切れの終局処理
    • 対局時間切れ => 終局処理
  • 両者ネットワーク切断された場合
    • => 終局処理をどこで完遂させるか考慮が必要

他にも細かい考慮点がたくさんあります。こんな感じで、対局機能の実現は中々考慮点の多い難しい機能になります。

最近はコーディングAIがすごいので2週間くらいで作れるだろうと思っていたんですが、まだまだこの辺を指示なく理解して実装してくれる感じではなさそうでした。特に設計が何通りもあるような機能の場合、コーディングエージェントだと機能要件は実現できてもかなり非効率や負債もりもりな感じの実装になったので、まだまだ複雑な機能を丸投げは難しいんだなという気持ちになっています。

最後に

最近転職したばかりで、本業の方もあり、そろそろ開発に手が回らなくなってきた感もあります。。

開発リソースに関してはどうしようかなと模索中です。。

久しぶりにコンピューターアーキテクチャに触れる(パタヘネ本 上巻)

はじめに

有給謳歌中なので、この機会にコンピューターアーキテクチャについて学び直そうと思い、パタヘネ本を読んでみました。 ボリュームが多いので、この辺復習する上で個人的に大事だと思う観点でまとめてみます。

筆者の背景知識

私自身は情報系の学部出身で、学生時代にFPGAで回路組んで簡単なCPUを作って、そこに自作のアセンブラ、コンパイラ(mini版C言語)を書いてC言語プログラムを実際に流し込む、みたいなことは経験しているため、パタヘネ本で書かれている基礎的な部分はだいたいイメージがついているという状態です。

※学生時代にその辺をしっかり学べたのは、今考えてもすごく良い経験だったなと思います

ただ、昨今の大規模データ処理・大規模AIモデル推論などで行われている最適化や、HPCとかの文脈をしっかりと知るには再度キャッチアップが必要だろうと思い、この機にこの辺も読んでみようと思ったという経緯です。

パタヘネ本は現在では第6版までになっているということで、GPU、TPUとかの話も結構盛り込まれているようです。本自体はめちゃくちゃボリュームが多いので、このブログではまず上巻(一部下巻の付録の内容も触れる)について備忘録として整理しておきます。

※深層学習に関連しそうな部分でいくと下巻が多そうなので、後編はまた別途整理して書きます。

備忘録

1章 コンピューターの抽象化とテクノロジ

導入的な話が多いのでほとんどスキップしますが、要点だけメモしておきます。

CPU性能

CPUの性能を考える時、あるプログラムを実行するのに必要なCPU実行時間は以下の式で表せる。

ハードウェア設計者の観点で性能を向上させるには、要はクロック周波数を上げるか、プログラム実行に必要なクロックサイクル数を減らせば良いことになる。

クロックサイクル数を減らすというのは、プログラム実行に必要な命令数を減らすか、命令あたりのクロック数を下げるというところになってきて、要はそのCPUや回路で実行できる命令の設計部分に関わる話。先ほどの式を分解すると以下のようになる。

あるプログラム実行に必要な命令数を減らすには、例えば、より複雑なことを可能な命令を実行できるように回路を組めば良いという話になると思いますが、そうすると大抵の場合はクロックサイクル時間をより多く取らなければいけなくなるため、簡単にはいかない話になります。

※その命令は早く実行できるようになるかもしれないが、全体の速度を下げてしまう。1つの観点だけでなく全体で最適化を考えなければいけないのがCPU設計の難しい点

命令セットが同じなら、あるプログラム実行に必要な命令数は同じになるため、CPI(命令あたりの平均クロックサイクル数)がCPU性能比較の一つの指標となります。

いずれにせよ、上式はCPU性能に影響する主要な3つの変数の関係を表しているので基本公式押さえておくべきもの。

※CPU性能で一般的なクロック周波数だけの比較では意味がない

ユニプロセッサからマルチプロセッサへの方向転換

本筋ではない歴史的背景の話だが、ユニプロセッサからマルチプロセッサに変わったのは、単に高速化のためだと思っていたが、実はマイクロプロセッサの冷却能力の制約によって、電力の限界にぶつかったことが理由ということだった。

電力=エネルギーであり、一般に消費電力を上げていくことで性能(クロック周波数)も上昇傾向にあったが、そこの限界に達したため、性能を上げるためにマルチプロセッサという方向転換になったそう。

マルチプロセッサになると、プログラムの実行性能を上げることに対して、プログラマに従来と異なり並列処理に関する工夫を強いることになるため、昔はコンピューターアーキテクチャ上のタブーとされてきたそう。

※今では当たり前になってきているため、転換には成功したと言えそう

2章 命令:コンピューターの言葉

ここでは、命令セットとしてMIPSを具体例として取り上げ、実際にどのような仕組みで命令が実行されるかを解説してくれている。

例えば、MIPSのアセンブリ言語で加算は以下のように表す。

add $s0 $s1 $s2

ここで、$s0, $s1, $2はレジスタを表す。この命令では$s1レジスタで保持されている値と$2レジスタで保持されている値を加算して$s0レジスタに反映させることを意味する。

上記はMIPSで用意されているアセンブリ言語の表記だが、これが実際には以下のフォーマットの32ビットのバイナリで表現される(MIPSで想定するレジスタ(32ビット)に格納できるサイズ)。

  • op(6ビット): 命令操作コード(add, sub, lwとか)
  • rs(5ビット):第1ソースオペランドのレジスタ番号
  • rt(5ビット):第2ソースオペランドのレジスタ番号
  • rd(5ビット):結果を収めるオペランドのレジスタ番号
  • shamt(5ビット):シフト量(シフト命令で利用)
  • funct(6ビット):機能コード

プログラマが書いたコードは、実際には上記のようなCPUで実行可能な大量の機械語命令に書き換えられて、実行されることになる。

命令は基本的に指定レジスタにある値について、組んだ回路を使って何らかの作用(算術演算、論理演算、シフト等)を行うことである。メモリからデータをレジスタにロードしたり、逆にレジスタからメモリにストアする命令もある(それぞれlw, sw)。

レジスタにも種類や数に限りがあるため、実際のプログラムの実行の際にはメモリを適切に組み合わせて命令が実行される。

  • 設計原則1:単純性は規則性につながる
    • 命令で使うオペランドの数が固定もしくは一定の範囲で決まっていれば、命令自体の表現を行う際も固定長で表現できるという大きな利点がある => ハードウェアの単純性を保つことができる
    • 特に命令自体も、できるだけオペランドと同じビット数で表現するように設計されているのが基本
  • 設計原則2:小さければ小さいほど高速になる
    • 例えばレジスタの数やビット数を増やせば増やすほど効率よくプログラムが実行できるように思えるが、それによるクロックサイクル速度や消費電力などへの影響も考えなければいけない
    • 加えて、レジスタの数が増やすと5ビットでは表現できなくなる問題もある
  • 設計原則3:優れた設計には適度な妥協が必要
    • 全ての命令を同じフォーマットにすると、現実的に表現するためのビット数が足りず不都合が起きる(lwでのメモリ上のアドレス指定など)
    • 全ての命令で同じフォーマットにするのを妥協し、命令によってフォーマットを変えるように設計されている(R形式、I形式など)

ある命令セット・アーキテクチャの性能を最大限発揮するには、十分な数のレジスタを備えており、コンパイラはレジスタを効率よく使用しなければならない。=> 逆にいうと、レジスタの数次第で最高の命令セット・アーキテクチャは変わるとも言えそう(CPU設計の観点でいくと)

条件分岐・ループ

基本的には条件判定用の命令(beq, bneなど)と、ジャンプ命令(j: 指定のアドレスに飛ぶ)をうまく組み合わせることで実現されている。

アドレスには人間が扱いやすいようにラベルを付与できるため、それを使用してj Elseなどのように命令を書く。

コンパイラによって行われている最適化についても多少書かれている(命令の配置順序など)。

手続き(関数)呼び出し

関数呼び出しは、呼び出し元のアドレスや、引数とか戻り値を記録する用にレジスタが用意されているので、それを活用して命令を組み立てる。

流れとしては以下のようなフローになる。

  • 手続きからアクセスできる場所にパラメータを置く
  • 手続きに制御を移す
    • 手続きの命令があるアドレスにジャンプ
    • 使用するレジスタの現在の値をメモリに退避(スタック)
  • 手続きに必要なメモリ資源を入手する
  • 必要なタスクを実行する
  • 呼び出し元のプログラムからアクセスできる場所に結果を置く
  • 制御を元の位置に戻す
    • メモリに退避した値をレジスタに復元
    • jr命令を使って元のアドレスにジャンプ

他にも、入れ子になった関数呼び出しについても解説がある。基本的にはスタック構造上で積んでいき、終わったら順に取り出していく。スタックポインタに正しく最新の参照先アドレスを記録しておく。

引数の数が用意されたレジスタの数を超える場合、手続き内の変数がレジスタの数を超える場合もスタックが使われる。そのほか、動的にサイズが変わるものはヒープ領域が割り当てられて使われる。

32ビットの定数およびアドレスの扱い

命令は32ビットに保てばハードウェアが単純化されるという話をしたが、32ビットの定数やアドレスを扱えた方が便利な場合もある。

32ビット定数定数

一方で、定数(即値)の場合は16ビットで想定して設計されている。そのため、32ビットの定数を扱いたい場合は、16ビットずつ2回に分けてレジスタにロードする形で32ビットの定数を作る

32ビットアドレス

アドレスの場合は、基本的に条件分岐や関数呼び出しでジャンプする先を指定するためのものであり、16ビットでは現在のプログラムを実行する際のジャンプ先数として当然足りないことが多い。そのため、32ビットを使うことになる。

条件分岐の場合は例えばbne命令などでジャンプ先のアドレスに使えるビット数は16ビットしか残っていないので、拡張する必要がある。

ここで、条件分岐の場合はジャンプ先も現在のアドレスの近くにあることが一般である(条件分岐の命令)。つまり、現在のアドレスからの差分であれば大抵の場合16ビットで表せることになる。

そのため、現在のアドレスを指すプログラムカウンタ値(PC, 32ビット)に分岐先アドレス(16ビット)を加算することで対応する(PC相対アドレシング)

関数呼び出しの場合は、現在のアドレスの近くにあるとは限らないため、32ビットのアドレスにランダムアクセスできるのが望ましい。ただし、関数ジャンプの場合は初めからjump命令を使うので、26ビットでアドレスを指定できる。

他にもアドレシングの方式は色々あるがここでは省略。

※なお、MIPSの命令長は4バイトと決まっているので、アドレスにおいても次の命令までのバイト・アドレスではなく、語アドレスを表している(これにより、より遠くまで表せる)。

コメント

コンパイラによる最適化の文脈で、コードレイアウト最適化と呼ばれるものもあるが、ここで解説されたようにジャンプ先をできるだけ近くにして少ないビット数で収まるようにするとかも、重要な最適化処理の一つ。

goとかでプロファイラーかませてビルドとかやったことはあるが、特にこの辺はより良くなるんじゃないかと思われ(Profile-Guided Optimization)。

コンパイラでやられてる他の最適化はここに色々書かれてる。
=> コンパイラ最適化 - Wikipedia

プログラムの翻訳と起動の流れ

CとJavaの違い。

その他

その他以下の話題にも触れているが省略。

CとJavaの違い、MIPSと、ARMv7/v8、RISC-V、x86の違い、x86の歴史など。

3章 コンピュータにおける算術演算

基本

算術演算特有で起こる問題であるオーバーフローについて、発生条件や判定方法などが解説されている。 その他、各種算術演算を実現するためのハードウェア、論理回路の概説や、高速化の歴史なども解説がある。

ここでは、MIPSにおいて実際に算術論理演算(AND、OR、加算、減算)を行うための基本部品であるALUを解説する。

1ビットALU

ALUは基本的に4つの信号を受け取り、2つの信号を出力する。

具体的には、入力としてa, b, キャリーイン, 制御入力を受け取り、出力として演算結果とキャリーアウトを返す。 キャリーインは下位の桁からの繰り上げで、キャリーアウトは上位の桁への繰り上げである。 制御入力はALUに依頼する操作を指定するために、下図のマルチプレクサに渡され、求める演算結果を出力させる。

実際にはもう少し複雑な機能を持たせるために、以下のようになる。

32ビットALU

上述したALUを32ビット分並べて組み合わせて使います。

実際には、ゼロ判定回路なども追加されている。ALUで可能な演算と制御信号は以下の通り。

この基本部品であるALUを使って、乗算や除算など他の演算の回路も組み立てられています(乗除算はシフトも組み合わせている)。

ちなみにこの構成だと、各ALUが隣のALUの繰り上げを受け取れる必要があるため、一つの加算を実行するにも32ステップが必要になります。 これでは効率が悪いので、実際には高速化のための工夫もされています。

浮動小数点数

浮動小数点数は、小数を含む実数を表現でき、さらに非常に大きな値や小さな値を効率よく表現できます。以下のような表記のイメージです。

1.234 x 104

2進数の場合は以下のような感じを想定してください。

1.101 x 25

ここで、浮動小数点数を表すには、仮数部と指数部という概念が存在します。 上記例では、仮数部は1.101、指数部は5となります。要は仮数部と指数部を表せれば実数を表せます。

32bitの浮動小数点(float)の場合、以下のように対応します。

[符号1bit][指数部8bit][仮数部23bit]

仮数部を多くとった方が有効桁数(つまり精度)は上げれますが、その分指数部は減って表せる範囲は狭くなります。この辺りはトレードオフになっており、どちらを優先するかでいくつかの型が用意されています。

機械学習周りでよく出てくるbfloatは、精度は下げてでもできるだけ表現範囲を広くするという考え方です。 機械学習では計算量やメモリ使用量を抑えるために少ないビット数で数値を表現したいが、オーバーフローやアンダーフローが発生すると困るという都合があります。逆に精度は多少ずれても問題になりにくかったため、よく使われていたと思います。

※ただし、LLMになってからは精度も求められてむしろ通常floatに戻ってきている

なお、演算操作は浮動小数点数の場合には扱いがさらに複雑になります。

SIMD

この辺りは普段のプログラミングにおいても重要になってくる部分。

"SIMD = Single Instruction, Multiple Data"であり、"1つの命令で、複数のデータを同時に処理する"概念です。

例えば、256ビットのレジスタがある場合、単精度のfloat値は4つ入れることができる。演算命令もその4つに対してまとめて行うことで、1ステップで4つ分の演算を完了させることができる。汎用のレジスタは基本64ビットだが、SIMD用に使われるレジスタだと現在では512ビットやそれ以上のものも出てきている。

SIMD用のレジスタ・演算を使うことで、配列計算、画像処理、音声処理、機械学習など、様々なベクトルや行列データを扱うような様々な処理で高速化が可能。GPUほどではないが、CPUでもある程度行列演算を高速化することが可能。

pythonでnumpyを使うと裏でこの辺の最適化を勝手にしてくれている。

4章 プロセッサ

ここでは、概要だけ触れておきます。

クロック方式

プロセッサは基本的にクロック信号に同期して動作するように設計されています。クロックが1回進むごとに、各回路が一斉に状態を更新することで、プログラムの実行が進んでいきます。

クロックは固定のサイクル時間で、電圧の高圧を切り替えて送られてくる信号です。このクロックの立ち上がりもしくは立ち下がりだけをアクティブとして、状態変化を引き起こすのが、現在主流のエッジ・トリガー方式です。

クロックのエッジのタイミングで、その時の状態の書き出しが行われます。そのため、エッジのタイミングではすべての信号が安定している必要があります。つまり、命令操作で経由する組み合わせ回路内のすべての信号が安定するのに十分なクロックサイクル時間を設定する必要があります。

この制約がクロック周期の下限を決定するため、設計原則で説明されたように、闇雲に複雑な回路を組むわけにもいかなくなってきます。複雑な命令を1クロックで可能にする回路を組んだとしても、クロックサイクル時間を増やさなければいけなくなると全体の命令実行に影響を及ぼします。この辺りがCPU設計の難しさの一つです。

一方で応用として、実際には1クロック・サイクルすべてを要さずに完了できる操作も一部あるが、そのような場合には、立ち上がりではなく立ち下がりも利用して、半クロック・サイクルで対応できるようにしている技術もあります(後述するパイプライン)。

フリップフロップ

状態を保存するためのメモリ要素の基礎的なものとして、フリップフロップがある。 フリップフロップは基本的にレジスタを構成するために使われる。

フリップフロップの実態は以下の通りで、クロック信号とデータ信号を受け取る回路によって構成されている。

Dフリップフロップの構造(※ChatGPTで生成)

このように自身の出力を受け取るような回路にすることで、メモリ要素として機能させることができる。これを32ビット分用意してレジスタを構成する。

省略するが、SRAMも似たようなフィードバックループによる回路として組まれている(用途は異なる)。DRAMはコンデンサとトランジスタが記憶の主要素。

パイプライン

下図で説明されているように、命令実行にはいくつかのステップがあるが、各ステップを分類すると使うリソースが異なることが分かる。アイドル状態になってしまっているリソースを有効活用するために、複数命令を並列でパイプライン的に実行することで、CPUの性能を向上させることができる。

だだし、実際にはそう簡単に性能を向上させることが可能なわけではなく、いくつかハザードと呼ばれるような問題が生じたり、ハードウェア的な制約もあります。この辺は複雑な話になってくるのでここでは省略します。

感想

ざっくりポイントを絞って整理しました。

学生時代にCPU構築した際にも、性能向上のために色んな工夫とか考えたりしたことはありますが、汎用的に性能を向上させるのは至難の業だということが改めて分かりました。 一方で、ドメイン特化の方向であれば結構色んな工夫ができそうで、改善をあーだこーだ考える分には楽しそうだなと思いました(もちろん実現性含めると簡単な話ではないですが)。

下巻はGPU含め、その辺の話にまで踏み込んでいるそうです(こちらも時間ができたら読む予定)。