薄いブログ

技術の雑多なことを書く場所

orisano/minid の話

github.com

上のツールを作ったので紹介の記事です.

背景

僕はアプリケーションエンジニアとしてDockerfileをそこそこ書くことがあるのですが,

巷にあふれるDockerfileがRUN一つにまとめられているのがずっと不思議に思っていました.

見栄えも悪いし, これってメンテナンス可能なんだろうか. そもそもなぜやっているんだろうと.

docs.docker.com

In older versions of Docker, it was important that you minimized the number of layers in your images to ensure they were performant. The following features were added to reduce this limitation:

In Docker 1.10 and higher, only the instructions RUN, COPY, ADD create layers. Other instructions create temporary intermediate images, and do not directly increase the size of the build.

In Docker 17.05 and higher, you can do multi-stage builds and only copy the artifacts you need into the final image. This allows you to include tools and debug information in your intermediate build stages without increasing the size of the final image.

上の記事を見ると, 古いバージョンのDockerにおいては, イメージのレイヤー数を最小化することが重要だったと書いてあります.

  • Docker 1.10 より前のバージョンのRUN, COPY, ADD以外の命令でもビルドのサイズが大きくなってしまう問題点
  • Docker 17.05 より前のバージョンではmulti-stage buildsが使用できない故に, イメージが大きくなりがちな問題, ツールを入れて使うのに気を使わないといけない問題

以上の問題点により, レイヤーの数がどうこうというよりイメージのサイズについて触れられていることが多いです.

17.05以降のバージョンを使える環境においてはRUN, COPY, ADDの数がレイヤーの数になりますし, multi-stage buildsを活用することでイメージのサイズは小さくできますのでレイヤー数がどうこうみたいなことは考えなくて良いと思います.

ちなみになぜイメージのサイズを意識するかというとpull, pushの速度に寄与するからだと思っています.

もしそのほかの観点でイメージのサイズを小さくする理由があるという方は教えていただけると幸いです.

orisano/minid

github.com

このツールは上の背景について理解してなかった僕が作った連続しているRUN, COPY, ADDを結合してくれるやつです.

機械的にRUN, COPY, ADDを結合することでイメージのサイズを小さくすることができます. しかし,レイヤー数が減るからイメージサイズが減るわけではないです.

厳密には違うのですが以下のページの図を参照していただけるとわかりやすいと思います.

AUFS ストレージ・ドライバの使用 — Docker-docs-ja 17.06.Beta ドキュメント

Dockerのイメージはファイルシステムの差分で構成されており, ファイルの削除はWhiteoutファイルで表現され, 移動などはOpaqueファイルで表現されています.

Dockerfileのすべてのレイヤーで独立なファイルを作成, 変更していればほとんど無駄がないので問題ありません.

しかし, RUNで実行されたコマンドがどういったファイルを作ったり変更したりするかをすべて把握しているかと聞かれれば怪しいと思います. (特にcache周りなど)

機械的にRUNをまとめることで, 自分が意識しないうちにレイヤーを跨いで触れているファイル分のサイズが小さくなります. これがサイズが小さくなる原理です.

また差分で保持しているという前提を知らなくても連続している場合のみ回避することができます.

一番重要なメリットしては, 人間が && を無限に書かなくても良くなるところです.

minidではbuildkitの内部にあるDockerfileのパーサを使用しASTを取得し, ランレングス圧縮の要領でまとめているだけです.

github.com

Dockerfileのparserは意外と簡単に使えるので使っておもちゃを作ってみると面白いかも知れません.

ぜひ面白いおもちゃができたときは教えてください.

終わりに

個人的にこのツールのあまり見ない点は, Dockerfileを受け取ってDockerfileを返す点だと思っています.

開発者が書きやすいDockerfileと小さく最適なDockerfileは別物だと思っていて,前者から機械的に後者を作れると信じているのでDockerfileを加工するシンプルなツールを作っています.

それをCI上でPipelineにすることで開発者の負荷を下げ, 高速なbuild, 高速なdeployの実現を夢みてやっています.

ツールが良いと思ったらGitHubでStarしてくれると励みになります.

kubeletの負荷が問題になった話 (google/cadvisor)

orisano.hatenablog.com

の続編です. 上の記事では以下の図の左側について書きましたが今回は右側についてです.

f:id:orisano:20181210232544p:plain

google/cadvisorの高速化

上の図は非常に見づらいですが, 右側にgoogle/cadvisorというのが見えると思います.

github.com

google/cadvisorというのはdocker containerのメトリクスを取るためのツールでWebUIなどが提供されています.

kubeletの内部でcadvisorが使われているのがpprofからわかります.

google/cadvisor/manager (*containerData).GetInfoを起点としてListContainers, ReadDir経由でSyscallを呼び出しており, Syscallに全体の20%程度の時間を使っていることがわかります.

まず調査のためにListContainersの実装を確認しに行きました.

cadvisor/handler.go at 8faf19092784b75fce10ce190d37e66f89de3612 · google/cadvisor · GitHub

cadvisor/helpers.go at 8faf19092784b75fce10ce190d37e66f89de3612 · google/cadvisor · GitHub

cadvisor/helpers.go at 8faf19092784b75fce10ce190d37e66f89de3612 · google/cadvisor · GitHub

ListDirectoriesがReadDirを呼び出していることがわかりました.

// Lists all directories under "path" and outputs the results as children of "parent".
func ListDirectories(dirpath string, parent string, recursive bool, output map[string]struct{}) error {
    entries, err := ioutil.ReadDir(dirpath)
    if err != nil {
        // Ignore if this hierarchy does not exist.
        if os.IsNotExist(err) {
            err = nil
        }
        return err
    }
    for _, entry := range entries {
        // We only grab directories.
        if entry.IsDir() {
            name := path.Join(parent, entry.Name())
            output[name] = struct{}{}

            // List subcontainers if asked to.
            if recursive {
                err := ListDirectories(path.Join(dirpath, entry.Name()), name, true, output)
                if err != nil {
                    return err
                }
            }
        }
    }
    return nil
}

非常にシンプルな実装になっています. ディレクトリ名を再帰的に取得し, 対象がディレクトリだったらmap[string]struct{}に存在するディレクトリを追加していく感じです.

どうにかSyscallの数を減らせないかと, io/ioutilのReadDirの実装を読むことにしました.

src/io/ioutil/ioutil.go - The Go Programming Language

// ReadDir reads the directory named by dirname and returns
// a list of directory entries sorted by filename.
func ReadDir(dirname string) ([]os.FileInfo, error) {
    f, err := os.Open(dirname)
    if err != nil {
        return nil, err
    }
    list, err := f.Readdir(-1)
    f.Close()
    if err != nil {
        return nil, err
    }
    sort.Slice(list, func(i, j int) bool { return list[i].Name() < list[j].Name() })
    return list, nil
}

os.(*File).Readdirを呼んでいるだけということがわかりました.

ちなみにcadvisorは基本的にlinuxでしか動かないので以下追っていくコードはlinuxの話になります.

src/os/dir.go - The Go Programming Language

// Readdir reads the contents of the directory associated with file and
// returns a slice of up to n FileInfo values, as would be returned
// by Lstat, in directory order. Subsequent calls on the same file will yield
// further FileInfos.
//
// If n > 0, Readdir returns at most n FileInfo structures. In this case, if
// Readdir returns an empty slice, it will return a non-nil error
// explaining why. At the end of a directory, the error is io.EOF.
//
// If n <= 0, Readdir returns all the FileInfo from the directory in
// a single slice. In this case, if Readdir succeeds (reads all
// the way to the end of the directory), it returns the slice and a
// nil error. If it encounters an error before the end of the
// directory, Readdir returns the FileInfo read until that point
// and a non-nil error.
func (f *File) Readdir(n int) ([]FileInfo, error) {
    if f == nil {
        return nil, ErrInvalid
    }
    return f.readdir(n)
}

これはprivateなreaddirをwrapしているだけです.

src/os/dir_unix.go - The Go Programming Language

func (f *File) readdir(n int) (fi []FileInfo, err error) {
    dirname := f.name
    if dirname == "" {
        dirname = "."
    }
    names, err := f.Readdirnames(n)
    fi = make([]FileInfo, 0, len(names))
    for _, filename := range names {
        fip, lerr := lstat(dirname + "/" + filename)
        if IsNotExist(lerr) {
            // File disappeared between readdir + stat.
            // Just treat it as if it didn't exist.
            continue
        }
        if lerr != nil {
            return fi, lerr
        }
        fi = append(fi, fip)
    }
    if len(fi) == 0 && err == nil && n > 0 {
        // Per File.Readdir, the slice must be non-empty or err
        // must be non-nil if n > 0.
        err = io.EOF
    }
    return fi, err
}

どうやら os.(*File).Readdirnamesでディレクトリ名一覧を取得して, それぞれに対してlstatを呼び出しています.

lstatはsyscallを呼び出すだけですが, Readdirnamesは何をしているかわからないので更に調査します.

src/os/dir.go - The Go Programming Language

// Readdirnames reads and returns a slice of names from the directory f.
//
// If n > 0, Readdirnames returns at most n names. In this case, if
// Readdirnames returns an empty slice, it will return a non-nil error
// explaining why. At the end of a directory, the error is io.EOF.
//
// If n <= 0, Readdirnames returns all the names from the directory in
// a single slice. In this case, if Readdirnames succeeds (reads all
// the way to the end of the directory), it returns the slice and a
// nil error. If it encounters an error before the end of the
// directory, Readdirnames returns the names read until that point and
// a non-nil error.
func (f *File) Readdirnames(n int) (names []string, err error) {
    if f == nil {
        return nil, ErrInvalid
    }
    return f.readdirnames(n)
}

privateなos.(*File).readdirnamesをwrapしているだけです.

src/os/dir_unix.go - The Go Programming Language

func (f *File) readdirnames(n int) (names []string, err error) {
    // If this file has no dirinfo, create one.
    if f.dirinfo == nil {
        f.dirinfo = new(dirInfo)
        // The buffer must be at least a block long.
        f.dirinfo.buf = make([]byte, blockSize)
    }
    d := f.dirinfo

    size := n
    if size <= 0 {
        size = 100
        n = -1
    }

    names = make([]string, 0, size) // Empty with room to grow.
    for n != 0 {
        // Refill the buffer if necessary
        if d.bufp >= d.nbuf {
            d.bufp = 0
            var errno error
            d.nbuf, errno = f.pfd.ReadDirent(d.buf)
            runtime.KeepAlive(f)
            if errno != nil {
                return names, wrapSyscallError("readdirent", errno)
            }
            if d.nbuf <= 0 {
                break // EOF
            }
        }

        // Drain the buffer
        var nb, nc int
        nb, nc, names = syscall.ParseDirent(d.buf[d.bufp:d.nbuf], n, names)
        d.bufp += nb
        n -= nc
    }
    if n >= 0 && len(names) == 0 {
        return names, io.EOF
    }
    return names, nil
}

ReadDirentで結果をbufに格納して, そのbufferの中身を解釈するためにParseDirentを呼び出します. それによりそのfd以下のエントリー一覧が取得でき,名前がわかるという流れになります.

ここでなんで名前しか取得できないんだろうと思い, ReadDirentの調査を行いました.

ReadDirentは内部的にgetdents(2)を呼び出していることがわかったのでbufferの中に何が入るのか調べることにしました.

getdents(2) - Linux manual page

上の記事を見るとわかりますが, linux 2.6.4以降ではlinux_direntにd_typeというのがありDT_DIRかどうかでディレクトリかどうかが判定でき, d_nameで名前が取得できることがわかります.

大本のListDirectoriesの処理を見てみると, ディレクトリかどうかの判定と名前の取得だけできればいいのでgetdents(2)で十分ということがわかります.

上のことが実現できれば, os.(*File).readdirにおけるlstatが不要になるので深く多いエントリがあるケースにおいてsyscallがかなり減ることが期待できます.

しかし, GoのParseDirentではd_typeが取得できないのでそこを実装する必要があります.

ここを最初自分で実装したのですが, cadvisorのLICENSEとgoのLICENSE周りでいろいろ面倒くさいことになりうまく説明できなかったので同じことをやっているライブラリを使うことにしました.

github.com

初めて実装ありきでライブラリを探した気がします. このライブラリを使って当該箇所を高速化しました.

github.com

これで5倍程度速くなりました.

kubeletの負荷が問題になった話 (prometheus/common)

ある日, 同僚からkubeletのCPU負荷が高くて困っていると相談されたときの話です.

実際に監視しているそのkubeletのCPU使用率は高いことを確認し, 問題が起きているインスタンスの調査をはじめました.

kubernetes/server.go at master · kubernetes/kubernetes · GitHub

ソースコードを見ればkubeletではpprofが有効になっているのがわかります.

実際にソースコードのどの部分の処理が負荷になっているのかを特定するためpprofを実行しました.

f:id:orisano:20181210232544p:plain

上のような結果が得られました. この結果からメトリクス取得系の処理が負荷を掛けているのではないかという仮設が立ちました.

DataDogによるメトリクス取得処理を行っていたので, それの頻度を下げることで一旦の問題は解決できるだろうということで落ち着きました.

せっかくプロファイルを取ったし, メトリクス取得が速くなっても誰も損しないだろうということでパフォーマンス・チューニングをはじめました.

prometheus/commonの高速化

pprofの結果画像の左の方をみてもらえるとわかると思うのですが, prometheus系の処理で若干の負荷がかかっていることがわかります.

github.com

手元でチューニングするためにcloneしてみたのですが, 当該箇所が無くなっていました. それは直近で以下のPRが出ていたためでした.

github.com

もともと遅かった箇所を高速化して, かつメモリを毎回確保する形をやめたというPRでした.

すでに別の人によって2倍程度速くなっていました. このままだとなんだか悔しいなと思い, benchmarkの結果を眺めていると改善後でも以下の関数が遅いことがわかりました.

func writeEscapedString(w enhancedWriter, v string, includeDoubleQuote bool) (int, error) {
    var (
        written, n int
        err        error
    )
    for _, r := range v {
        switch r {
        case '\\':
            n, err = w.WriteString(`\\`)
        case '\n':
            n, err = w.WriteString(`\n`)
        case '"':
            if includeDoubleQuote {
                n, err = w.WriteString(`\"`)
            } else {
                n, err = w.WriteRune(r)
            }
        default:
            n, err = w.WriteRune(r)
        }
        written += n
        if err != nil {
            return written, err
        }
    }
    return written, nil
}

この関数はソースコード見ればわかりますが, 特定文字をエスケープしつつ文字列をWriteしたいという関数です.

一文字ずつ検査し, エスケープ対象であればエスケープしたものをWriteして, それ以外ならWriteRuneをするというものです.

これで一見問題ない様に見えるのですが, 大量にWriteRuneを呼んでおりその結果syscallが大量に発行されていることがわかりました.

この処理をみて, strings.ReplacerにWriteStringがあることを思い出しました.

https://golang.org/pkg/strings/#Replacer.WriteString

そもそもstrings.Replacerは内部的に4種類の実装があり, 引数を元に実装を切り替えます.

src/strings/replace.go - The Go Programming Language

NewReplacerの実装を読んだほうが早いし正確ですが,

  • 置換対象が一つのときに使われるsingleStringReplacer
  • 置換元がすべて1byteで置換先もすべて1byteのときに使われるbyteReplacer
  • 置換元がすべて1byteで置換先は1byteではないときに使われるbyteStringReplacer
  • それ以外のときに使われるgenericReplacer

singleStringReplacerは内部でstringFinderを用いており, stringFinderはBM法で実装されています.

byteReplacerはすごくシンプルな実装になっていて, [256]byteを持ってbyte->byteの対応をもって愚直に変換していくだけです.

byteStringReplacerも基本的な発想はbyteReplacerと一緒で[256][]byteをもって変換していく感じです. ある程度の枝刈りがなされています.

genericReplacerはTrieで実装されています. 上の特定のケースに最適化されたreplacerよりは遅いですが普通に使えるものだと思います.

今回の実装の場合だと1byteを複数byteに置き換えるのでbyteStringReplacerが内部的に使われます.

byteStringReplacerのWriteStringの内容は以下で確認できます.

src/strings/replace.go - The Go Programming Language

置換対象の文字列でない場合は書き込まず, まとめて書き込んでくれる実装になっておりWriteの回数を少なくしてくれそうです.

また余計なメモリの確保なども行わない実装になっているのでAllocation-Freeの文脈でも問題なく使用できます.

今回は内部に条件分岐が存在しますが, 引数で決定するものかつ, 2種類しか値を持たないものなのでStringReplacerを2種類作ることで対応できます.

github.com

以上の改善で, 更に2倍近く高速にすることができました.

Socket.IOとGoの話

speakerdeck.com

goでsocket.ioのclientを実装した話です.

背景

socket.io を使ったアプリケーションに負荷試験をしたいというのがありました.

go で書きたいという気持ちとよく考えたら socket.io のことあまり知らないなという気持ちから実装してみようと思い始めたものです.

はじめに

Socket.IOとは

FEATURING THE FASTEST AND MOST RELIABLE REAL-TIME ENGINE

を謳っているリアルタイム双方向event-basedなエンジンです.

Node.jsで実装されており, キラーアプリとしてNode.jsの普及に貢献したイメージがあります. 間違っていたらすいません.

Socket.IOは現在version 2.0まで出ており, 今回話すのもversion 2.0の話です.

よくある誤解というかあまり意識せずに Socket.IO == WebSocket という人もいるように感じます.

実際には Socket.IO !== WebSocket です.

むしろ Socket.IOはWebSocketの対応状況が悪かった時期に同様のことをするためのものだったという理解です.

Socket.IOの中ではTransportの部分は抽象化されており, その部分はEngine.IOと呼ばれています, 必ずしもWebSocketではありません.

Engine.IOはかなり簡素化されたRealtime Engineでeventなどの概念は存在しません. Engine.IOの仕様として

の3つのTransportをサポートしなければいけません. Engine.IOを通すことで上の層ではこれらを意識しなくて良くなります.

別に今の時代 WebSocketでいいだろと思った方は以下の記事にWebSocketを使う上で考えなければいけないことがまとまっているので読んでみてください.

blog.jxck.io

インターネットは難しく, リアルタイム通信ができないと価値を提供できないものの場合はWebSocketだけではなくfallbackを考えなければいけず, それが実現されているSocket.IOを使うという選択肢はあると思います.

Engine.IO

Engine.IOプロトコルに関しては以下のリポジトリにまとまっています.

github.com

最新はrevision 3でrevision 3の話をします. 見てみるとわかるのですがテキストベースのすごくシンプルなものになっています.

<packet type id>[<data>] という形式でpacket type idも7種類しかありません.

  • open
  • close
  • ping
  • pong
  • message
  • upgrade
  • noop

接続の維持, 識別, データの送信などの基本的な操作しかない.

非常に簡単で人間が手でも書くことができる. 上のスライドには載せてあるが実際に手でうって確認することができるリポジトリがあるので興味があればそちらを使ってみてください.

github.com

Socket.IO

Socket.IOのプロトコルについては以下のリポジトリにまとまっています.

github.com

今はrevision 4でrevision 4の話をします.

Engine.IOのmessageのpayload部がSocket.IOのPacketになります.

Packetは7種類しかなく基本的にテキストベースで非常にシンプルなプロトコルであることがわかります.

  • CONNECT
  • DISCONNECT
  • EVENT
  • ACK
  • ERROR
  • BINARY_EVENT
  • BINARY_ACK

です. 一応バイナリも対応しています.

上のリポジトリだけ見るとどういった形式で格納されているかは触れられていないので実際に使われているパーサーの実装を読みましょう.

github.com

実装を見てみると以下の形式であることがわかります.

<packet type id>[(attachments)-][(namespace:/),][id][data]

シンプルですがEngine.IOと比べるとnamespaceなどの概念が追加されています.

実際に流れるのが以下のようなテキスト列になります.

先頭の記号は通信の向きなので実際には送られないものです. // 以降はコメントで e:はEngine.IOのpacket type, s:はSocket.IOのpacket typeになっています.

> 0 // e:open
> 40 // e:message s:connect
< 42[“message”,”hello”] // e:message s:event data > 42[“reply”,”?hello”] // e:message s:event data
< 41 // e:message s:disconnect
< 42[“message”,”hello”] // e:message s:event data < 40 // e:message s:connect
< 42[“message”,”hello”] // e:message s:event data > 42[“reply”,”?hello”] // e:message s:event data

こういったテキストを送れる様になるだけでSocket.IOをしゃべることができます.

これをgoで実装したライブラリが以下です.

github.com

基本的にはこういう感じでパケットを組み立てるだけのライブラリなのですが, Engine.IOのレイヤーでping/pongをしてくれます.

このライブラリから普通にNode.jsのSocket.IOとも話すことができます.

実はこのライブラリ, 途中で力尽きており完全に仕様を網羅しているわけではないのでご了承いただければと思います.

終わりに

当初の負荷試験がしたいという目的は忘れてYak shavingに励んでいました.

でも楽しかったので問題なしです.

go-swaggerを速くした話

orisano.hatenablog.com

orisano.hatenablog.com

github.com

go-swaggerのgenerate serverを速くした話をします.

上の2つの記事であげたPRでgo-swaggerのコード生成を速くしました.

上の2つの記事ではどこが遅いか明らかになった状態からのことを書きましたが, この記事ではどうやって明らかにしたかを書きます.

と言っても非常に簡単でpprofを使っただけです.

runtime/pprof

net/http/pprof

今回はpprofを直接使ったわけではなく,

github.com

という非常に簡単に使えるライブラリ経由で使用しました. 本当にCLIなどの計測の際はこれで十分な気がします.

defer profile.Start().Stop()

上のようなコードをgo-swaggerのmain関数に追加してbuild, 実行しました. go tool pprof -svg [出力されたファイル] とすると以下のような画像が出力されます.

f:id:orisano:20181204032618p:plain

このグラフを見ると全体像から重い場所がわかると思います. こういった形で簡単に計測, 改善場所の発見ができるはずです.

この過程で, Analyzedが重いことと無駄にregexp.MustCompileを呼んでいることが発見できました.

また, これとは違う見方でFlameGraphというものがあります. これもpprofのファイルから作れる

github.com

というものがあるので分析するときの助けになると思います.

これはCLIを簡単に計測するときは github.com/pkg/profile でいいと思いますが,

サーバーアプリケーションやデーモンの場合はユーザがみたいタイミングで外部からpprofできるので net/http/pprof を使ったほうが良いです.

しかし問題は得てして観測していないときに起こったりするので定期的にpprofを取りたいというときは

github.com

という選択肢もありかも知れません.

いずれにせよ本番環境で有効にする場合はインターネットからつながらないように気をつけてください.

まとめ

  • CLIの高速化のときは github.com/pkg/profileを使うと便利
  • サーバーアプリケーションやデーモンの場合は net/http/pprof を使うと便利
  • go tool pprof -svg で全体感を把握できる.
  • go-torch で重い処理を深掘りしていける.
  • 今回の取り組みと工夫で swagger generate serverが11秒から3秒になった.

go-openapi/swagを速くした話

github.com

GitHub - go-openapi/swag: goodie bag in use in the go-openapi projects を速くしたときの話をしたいと思います.

go-openapi/swag はgo-openapi と go-swagger のヘルパー関数が入っているライブラリです.

調査の結果, regexp.MustCompileが重くToGoName経由でspllitという関数から呼ばれていることがわかっていました.

実際に見てみると以下のような実装になっていました.

// Prepares strings by splitting by caps, spaces, dashes, and underscore
func split(str string) []string {
    repl := strings.NewReplacer(
        "@", "At ",
        "&", "And ",
        "|", "Pipe ",
        "$", "Dollar ",
        "!", "Bang ",
        "-", " ",
        "_", " ",
    )

    rex1 := regexp.MustCompile(`(\p{Lu})`)
    rex2 := regexp.MustCompile(`(\pL|\pM|\pN|\p{Pc})+`)

    str = trim(str)

    // Convert dash and underscore to spaces
    str = repl.Replace(str)

    // Split when uppercase is found (needed for Snake)
    str = rex1.ReplaceAllString(str, " $1")

    // check if consecutive single char things make up an initialism
    once.Do(ensureSorted)
    for _, k := range initialisms {
        str = strings.Replace(str, rex1.ReplaceAllString(k, " $1"), " "+k, -1)
    }
    // Get the final list of words
    //words = rex2.FindAllString(str, -1)
    return rex2.FindAllString(str, -1)
}

見るとわかりますが, regexp.MustCompileが二回呼び出されています.

regexp.MustCompileは重い処理で, 取っている引数が定数なので毎度計算する必要がないことがわかります.

またこれは蛇足ですが, strings.Replacerにおいても同様のことが言えます.

こういったケースにおいて, initでグローバル変数で予め計算したものを保持しておくパターンか,sync.Onceを使って初回に一度だけ計算するパターンのどちらかで対処できます.

initを使う場合は必ず実行されてしまうので起動時に初期化のコストがかかってしまい, sync.Onceの場合は呼び出されたタイミングで実行されるので必要じゃない場合にコストはかからないが呼び出しごとに若干のオーバーヘッドがかかってしまいます. 一長一短なので考えて採用しましょう.

またグローバル変数にして大丈夫かをドキュメントで確認しておきましょう.

そのオブジェクトのメソッドが並列に呼び出すことをサポートしてない場合は上の方針は採用できません.

標準ライブラリの場合は書いてあることが多いように思います.

regexp - The Go Programming Language

A Regexp is safe for concurrent use by multiple goroutines, except for configuration methods, such as Longest.

strings - The Go Programming Language

It is safe for concurrent use by multiple goroutines.

確認して上記方針で問題ないことがわかりました.

今回の場合は, sortの処理だけsync.Onceを用いていたのでそこに乗っかる形で修正しました.

ベンチマークの結果, 3倍程度速くなりました.

まとめ

regexp.MustCompileやstrings.NewReplacerは重い.

重い構築系の関数はsync.Onceやグローバル変数に逃がすことで解決できることがある.

ちゃんと複数のgoroutineで読んでも安全かどうかをdocumentで確認する.

用法用量を守ったチューニングをしていきましょう.

go-openapi/loadsを速くした話

github.com

GitHub - go-openapi/loads: openapi specification object model の速度を改善したときの話をしたいと思います.

go-openapi/loadsはgoでopenapiのschemaを読み込むためのライブラリです.

調査の結果json.Unmarshalが重く,このライブラリのAnalyzedという関数から呼び出されていることがわかっていました.

実際に見てみると以下のような実装でした.

// Analyzed creates a new analyzed spec document
func Analyzed(data json.RawMessage, version string) (*Document, error) {
    if version == "" {
        version = "2.0"
    }
    if version != "2.0" {
        return nil, fmt.Errorf("spec version %q is not supported", version)
    }

    raw := data
    trimmed := bytes.TrimSpace(data)
    if len(trimmed) > 0 {
        if trimmed[0] != '{' && trimmed[0] != '[' {
            yml, err := swag.BytesToYAMLDoc(trimmed)
            if err != nil {
                return nil, fmt.Errorf("analyzed: %v", err)
            }
            d, err := swag.YAMLToJSON(yml)
            if err != nil {
                return nil, fmt.Errorf("analyzed: %v", err)
            }
            raw = d
        }
    }

    swspec := new(spec.Swagger)
    if err := json.Unmarshal(raw, swspec); err != nil {
        return nil, err
    }

    origsqspec := new(spec.Swagger)
    if err := json.Unmarshal(raw, origsqspec); err != nil {
        return nil, err
    }

    d := &Document{
        Analyzer: analysis.New(swspec),
        schema:   spec.MustLoadSwagger20Schema(),
        spec:     swspec,
        raw:      raw,
        origSpec: origsqspec,
    }
    return d, nil
}

パッと見ると遅いjson.Unmarshalが2回同じ対象に実行されていることがわかります.

詳細な背景はわかりませんが, どうやら一つはプログラム中で変更してしまうもので元のデータを保持しておきたいためのものだとわかります.

つまり, 2回目のjson.UnmarshalはjsonをUnmarshalしたい目的ではなくswspecと同等のものを用意したい, deep copyがしたいという意図でした.

json.Unmarshalのコストが低ければこれでも問題ないと思うのですが, spec.Swaggerはjson.Unmarshal時にOpenAPIの仕様を満たすために独自のjson.Unmarshalerが実装されています.

どうなっているか確認してみました.

spec/swagger.go at 93213dab6b424cc2dd3fe3aab33e6c5660aa3343 · go-openapi/spec · GitHub

func (s *Swagger) UnmarshalJSON(data []byte) error {
    var sw Swagger
    if err := json.Unmarshal(data, &sw.SwaggerProps); err != nil {
        return err
    }
    if err := json.Unmarshal(data, &sw.VendorExtensible); err != nil {
        return err
    }
    *s = sw
    return nil
}

まず最初にswaggerが満たすべき最低条件のためにsw.SwaggerPropsをjson.Unmarshalし, X-から始まる拡張用のフィールドのために再度json.Unmarshalする実装になっています.

それ以外にもspec.Swaggerのjson.UnmarshalはX-から始まる拡張用フィールドをいたるところでサポートしているので普通のものと比べて重い処理であることがわかります.

一度jsonを解釈してgoの構造体に落としているのだから, deep copyのためだけに再度jsonを解釈するコストが無駄なように思えます.

確かに構造体をポインタなども含めてちゃんとdeep copyをするコードを書くのは面倒くさいのでやりたくない気持ちは理解できます.

Golang Benchmark: gob vs json · GitHub

をみてgobにencodeしてからdecodeするほうがjson.Unmarshalによるdeep copyより速いのではないかと思いました.

実際に実装して, Benchmarkのコードを書いてみると2倍くらい速度の改善が見られました.

しかし,どんな場合でもjson.Unmarshalよりgobのほうが優れているということはないはずなのでちゃんと計測を行いましょう.

そしてgobを実際に使用する場合, 事前にgob.Registerで登録していないと[]interface{}map[string]interface{}がうまく扱えないので注意してください.

また,encodeしてdecodeする形式は一時的にメモリを使用するという点とちゃんと実装されていなければ公開されていないフィールドは扱えないのでそこに注意する必要があります.

ちなみにPRの本文に書いてあるようなBenchmark間の差を見たい場合は

perf/cmd/benchstat at master · golang/perf · GitHub

のようなツールが非常に便利です. ぜひ使ってみてください.

最後に

このようなケースにおいて, 高速なdeep copyを実現する場合にもっと優れている方法を知っている方は教えていただけると幸いです.

もしくはPRを送ってもらってもいいと思います.