nakarioのほぼISUCONブログ

ISUCON出るたびブログ書く

ISUCONでAIを最大限活かすためのツールを作りました #isucon14

チーム「百万円ドリブン」のnakarioです。今年もISUCON参加してきました!今回は参加時にやったことの紹介はやめにして、私が作成したISUCONでAIを最大限活用するためのツールを紹介したいと思います!

……本戦の結果がダメダメだったからです!

AI最高!

AI良いですよね!質問に答えてくれたり、ちょっとしたコードを書いてくれたりします。今回私はGitHub Copilotに課金してVS CodeでCopilotを使えるようにしました。私がSQLクエリを書くといつもsyntax errorで怒られるのですが、Copilotに書いてもらうと私よりはマシなコードを書いてくれます!それにGoのif err != nilなどの面倒なコードもサクッと生成してくれるので気持ちよくコーディングできます。

その中でもCopilot Editの機能は素晴らしいです!指示に従って既存のコードを書き直してくれるので、上手く使えばN+1の解消やキャッシュの有効化などが一瞬で出来上がります。ミスをすることもあるとはいえ、大筋あっていれば手で修正することも可能なので、大幅スピードアップに貢献してくれています。

そんなCopilot Editですが、不満が一つあります。ファイル全体を書き換えるので、長いファイルをEditすると結構時間がかかることです。試しに今回のapp_handlers.goに対して「appGetNotification関数をhogeにリネームして」とリクエストしてみると、だいたい54秒かかりました。appGetNotification関数自体はファイルの真ん中より下の方にあるので、編集マーカーが上の方から流れていくのを見ている時間は虚無になります。

そこでファイルを細かい単位に分割することでCopilot Editの時間を短縮することにしました。手で一個一個のファイルを分割していると時間がかかりすぎるので、自動でファイルを分割するツールを作成しました。

ファイル自動分割ツール fsplit

github.com

$ go install github.com/nakario/fsplit/cmd/fsplit@latest
$ fsplit .

fsplitに対してディレクトリを渡してやると、そのディレクトリ内のパッケージを関数ごとに1つのファイルになるように分割します。関数以外の型定義やグローバル変数はもとのファイルに残ります。今回のISUCONのgoのディレクトリに対してfsplitを実行してやると、.goファイルは以下のように分割されます

app_handlers._.abs.fsplit.go
app_handlers._.appGetNearbyChairs.fsplit.go
app_handlers._.appGetNotification.fsplit.go
app_handlers._.appGetRides.fsplit.go
app_handlers._.appPostPaymentMethods.fsplit.go
app_handlers._.appPostRideEvaluatation.fsplit.go
app_handlers._.appPostRides.fsplit.go
app_handlers._.appPostRidesEstimatedFare.fsplit.go
app_handlers._.appPostUsers.fsplit.go
app_handlers._.calculateDiscountedFare.fsplit.go
app_handlers._.calculateDistance.fsplit.go
app_handlers._.calculateFare.fsplit.go
app_handlers._.getChairStats.fsplit.go
app_handlers._.getLatestRideStatus.fsplit.go
app_handlers.go
chair_handlers._.chairGetNotification.fsplit.go
chair_handlers._.chairPostActivity.fsplit.go
chair_handlers._.chairPostChairs.fsplit.go
chair_handlers._.chairPostCoordinate.fsplit.go
chair_handlers._.chairPostRideStatus.fsplit.go
chair_handlers.go
internal_handlers.go
main._.bindJSON.fsplit.go
main._.main.fsplit.go
main._.postInitialize.fsplit.go
main._.secureRandomStr.fsplit.go
main._.setup.fsplit.go
main._.writeError.fsplit.go
main._.writeJSON.fsplit.go
main.go
middlewares._.appAuthMiddleware.fsplit.go
middlewares._.chairAuthMiddleware.fsplit.go
middlewares._.ownerAuthMiddleware.fsplit.go
middlewares.go
models.go
owner_handlers._.calculateSale.fsplit.go
owner_handlers._.ownerGetChairs.fsplit.go
owner_handlers._.ownerGetSales.fsplit.go
owner_handlers._.ownerPostOwners.fsplit.go
owner_handlers._.sumSales.fsplit.go
owner_handlers.go
payment_gateway.go

……多いですね!

今回はレシーバーを持つ関数定義がなかったので全部間に ._. という文字列を含んでいますが、例えばhoge.goのFuga構造体をレシーバーに持つpiyoメソッドなら、 hoge.Fuga.piyo.fsplit.go となります。

分割された.fsplit.goファイルはパッケージ宣言とimport文、単一の関数定義のみを持つファイルになっています。例えば先程のappGetNotificationを持つファイル app_handlers._.appGetNotification.fsplit.go は次のようになります。

package main

import (
	"database/sql"
	"errors"
	"net/http"
)

func appGetNotification(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	user := ctx.Value("user").(*User)

	tx, err := db.Beginx()
	if err != nil {
		writeError(w, http.StatusInternalServerError, err)
		return
	}
	defer tx.Rollback()

	ride := &Ride{}
	if err := tx.GetContext(ctx, ride, `SELECT * FROM rides WHERE user_id = ? ORDER BY created_at DESC LIMIT 1`, user.ID); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			writeJSON(w, http.StatusOK, &appGetNotificationResponse{
				RetryAfterMs: 30,
			})
			return
		}
		writeError(w, http.StatusInternalServerError, err)
		return
	}

	yetSentRideStatus := RideStatus{}
	status := ""
	if err := tx.GetContext(ctx, &yetSentRideStatus, `SELECT * FROM ride_statuses WHERE ride_id = ? AND app_sent_at IS NULL ORDER BY created_at ASC LIMIT 1`, ride.ID); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			status, err = getLatestRideStatus(ctx, tx, ride.ID)
			if err != nil {
				writeError(w, http.StatusInternalServerError, err)
				return
			}
		} else {
			writeError(w, http.StatusInternalServerError, err)
			return
		}
	} else {
		status = yetSentRideStatus.Status
	}

	fare, err := calculateDiscountedFare(ctx, tx, user.ID, ride, ride.PickupLatitude, ride.PickupLongitude, ride.DestinationLatitude, ride.DestinationLongitude)
	if err != nil {
		writeError(w, http.StatusInternalServerError, err)
		return
	}

	response := &appGetNotificationResponse{
		Data: &appGetNotificationResponseData{
			RideID: ride.ID,
			PickupCoordinate: Coordinate{
				Latitude:  ride.PickupLatitude,
				Longitude: ride.PickupLongitude,
			},
			DestinationCoordinate: Coordinate{
				Latitude:  ride.DestinationLatitude,
				Longitude: ride.DestinationLongitude,
			},
			Fare:      fare,
			Status:    status,
			CreatedAt: ride.CreatedAt.UnixMilli(),
			UpdateAt:  ride.UpdatedAt.UnixMilli(),
		},
		RetryAfterMs: 30,
	}

	if ride.ChairID.Valid {
		chair := &Chair{}
		if err := tx.GetContext(ctx, chair, `SELECT * FROM chairs WHERE id = ?`, ride.ChairID); err != nil {
			writeError(w, http.StatusInternalServerError, err)
			return
		}

		stats, err := getChairStats(ctx, tx, chair.ID)
		if err != nil {
			writeError(w, http.StatusInternalServerError, err)
			return
		}

		response.Data.Chair = &appGetNotificationResponseChair{
			ID:    chair.ID,
			Name:  chair.Name,
			Model: chair.Model,
			Stats: stats,
		}
	}

	if yetSentRideStatus.ID != "" {
		_, err := tx.ExecContext(ctx, `UPDATE ride_statuses SET app_sent_at = CURRENT_TIMESTAMP(6) WHERE id = ?`, yetSentRideStatus.ID)
		if err != nil {
			writeError(w, http.StatusInternalServerError, err)
			return
		}
	}

	if err := tx.Commit(); err != nil {
		writeError(w, http.StatusInternalServerError, err)
		return
	}

	writeJSON(w, http.StatusOK, response)
}

このように分割された状態のファイルに対して先ほどと同じクエリを実行すると、およそ5秒で編集が完了しました。約10倍の高速化ですね!

fsplitの問題点

fsplitによってファイルを分割すると、もともとのファイルでは同じファイルに含まれていた型定義や、関数内で呼び出している他の関数の情報を参考にすることができなくなります。こうなると存在しない構造体のメンバーや、関数の返り値の数の不一致など色々な生成の不具合が生じやすくなります。

これを解決するために、もう一つのコマンドを用意しました。

github.com

$ go install github.com/nakario/depf/cmd/depf@latest
$ depf app_handlers._.appGetNotification.fsplit.go .
app_handlers._.abs.fsplit.go
app_handlers._.appGetNotification.fsplit.go
app_handlers._.calculateDiscountedFare.fsplit.go
app_handlers._.calculateDistance.fsplit.go
app_handlers._.getChairStats.fsplit.go
app_handlers._.getLatestRideStatus.fsplit.go
app_handlers.go
main._.writeError.fsplit.go
main._.writeJSON.fsplit.go
main.go
models.go
owner_handlers.go

depfは指定されたファイル内の識別子ごとに定義された場所を確認し、定義元ファイルの一覧を標準出力に列挙します。外部のパッケージは一覧から除外しています。ISUCONにおいてはwell-knownのパッケージが多いのと、何より数が膨大になってしまうからです。

これを使って code -n $(depf app_handlers._.appGetNotification.fsplit.go .) のようにVS Codeを起動すると、上のdepfのサンプルで出力されてるファイルだけが開かれたVS Codeの新しいウインドウが開くので、そこでCopilot Editのペインを開いて + Add Files... から Open Editors を選択すると、必要なファイルがすべて参照された状態でCopilot Editによる編集が可能になります!ただし編集対象もすべての開いているファイルになるので、Copilotへの指示に明示的にどのファイルを編集するかを明示してやる必要があります。

分割したファイルのみを開いて指示した場合
必要なファイルを全て開いて指示した場合

画像で示したように、単一のファイルのみを参照すると失敗する場合でも必要なファイルのみを開いて指示することで適切な処理を実行できるようになりました!

まとめ

本当は本戦で良い成績を残したうえでこのツールのおかげです!ってやりたかったのですが、なかなかうまく行きませんね!来年も開催されれば参加して、fsplit / depf含めてツールを活かして100万円とれるよう頑張ります!!