kakts-log

programming について調べたことを整理していきます

詳解システムパフォーマンス第6章 CPUアーキテクチャのまとめ

詳解 システム・パフォーマンス

詳解 システム・パフォーマンス

最近発売された「詳解システムパフォーマンス」を買って読み進めています 第6章のCPUアーキテクチャの項目が勉強になったので、簡単にまとめます。

CPUの基礎用語

  • プロセッサ
    プロセッサボードのソケットなどに装着される物理チップ。 一つ以上のCPUをもつ
  • コア マルチコアプロセッサに含まれる、独立したCPUインスタンス CMP(chip level multi processing)と呼ばれるprocessorのスケーリング方法として使える
  • ハードウェアスレッド 1コアの上で複数スレッドの並列実行をサポートするCPUアーキテクチャのこと。各スレッドは独立したCPUインスタンスとして扱われる。 マルチスレッディングはこのスケーリングアプローチの内の一つ
  • CPU命令
    CPU命令セットに含まれる1個のCPUオペレーション
  • 論理CPU
    仮想プロセッサとも呼ばれる。 OSのCPUインスタンスであり、ハードウェアスレッド、コア、シングルコアプロセッサのどれかで実装される

CPU architecture

4コア、8ハードウェアスレッドをもつシングルプロセッサを考えた場合、以下のように図で表せる。
f:id:kakts:20170309235424j:plain

ハードウェアスレッドは、それぞれ1個の論理CPUとして扱うことができるため、OSからこのシングルプロセッサを見た時、8個のCPUを持っているように見える。 OSはさらにスケジューリングの最適化を行うために、どのCPUがどのコアに乗っているかといった情報を持っている場合がある。

CPU memory cache

プロセッサには様々なハードウェアキャッシュを持っており、メモリI/Oのパフォーマンス向上のために有用である。
キャッシュのサイズとI/Oスピードはトレードオフの関係にあり、高速になるほどCPUの近くに位置する。 キャッシュには、大きく分けて2種類あり、プロセッサの中、外にあるかで分けられる。
プロセッサ内にあるものは統合キャッシュと呼ばれることがあり、現在では主流となっている。

CPU run queue

スレッドには2つの実行状態があり、CPU上で実行中の場合と、CPUでの処理を待っている実行可能状態がある。
処理待ちのスレッドは、カーネルスケジューラにより、CPUのランキューにキューイングされ、順番にCPUの処理を待つ。
ランキューにキューイングされたスレッドの数は、CPUの飽和状態を示す重要なパフォーマンス指標となる。

マルチプロセッサシステムにおいて、カーネルはCPUそれぞれにランキューをもち、スレッドを同じランキューにキューイングし続けようとする。
なぜ同じランキューを選ぶかというと、CPUはスレッドのデータをキャッシュするため、パフォーマンスが良くなるからである。
この、スレッドに対して特定のランキューを選ぶアプローチをCPUアフィニティ と呼ぶ。

Goのスライスについて

Goのスライスについて

Go Slices: usage and internals - The Go Blog
Go言語において固定長のサイズの配列とは別に、要素の追加に応じて自由にサイズを拡張できるスライスという型があります。

スライスのデータ構造

スライスのデータ構造としては、ソースコードを読んでみるのが早くて、以下のurlにまとまっています。 https://github.com/golang/go/blob/master/src/runtime/slice.go#L11-L15

type slice struct {
    array unsafe.Pointer // 配列のポインタ
    len   int   // スライスが現在もっている要素数
    cap int    // スライスのサイズ
}

スライスの内部的には、配列のポインタを持っているため、従来の配列と同様の操作が行えます。

さらには、現在スライスが現在持っている要素数lenをもっていて、要素を追加するたびにこの値が増加していきます。 lenとは別に、スライスの宣言時に初期化した要素に応じてcapも決められます。 それと同時に、lenとcapをチェックし、capの値を超えたら、古いcapサイズの2倍のサイズのメモリを確保(malloc)し、同じ要素でサイズが2倍のスライスを新たに作ります。

このあたりの処理はsliceの内部実装で使われているgrowsliceという関数で実現されています。 go/slice.go at master · golang/go · GitHub
読んでみると、スライス拡張の際に新たにメモリサイズを計算し、mallocして新たにメモリが割り当てられていることが理解できると思います。

スライスの宣言

スライスを使う際に、宣言する方法は配列のものと非常に似ていて、下記のように書くことができます。 makeを使ってcapとlenを指定した上で宣言する事もできます。

// 配列の宣言
// 宣言時に[]にサイズを指定する。固定長のため、サイズが増えることはない
arr := [4]int{1, 2, 3, 4}


// スライスの宣言
// サイズは指定せず、4つの要素を指定して初期化している。
sl := []int{1, 2, 3, 4}

// makeを使って、サイズを決めた上で宣言も行える
sl := make([]int, 4, 8)

スライスの要素の操作

スライスは宣言時に自由に要素を指定することができ、指定した要素に応じて長さが決まることを説明しました。

このスライスに対して、様々な操作を行うことができます。

sl := []int{1, 2, 3, 4}
// 元のスライスの1番目から(4-1)番目の要素をもつスライスを取得
fmt.Println(sl[1:4]) //  [2 3 4]と表示
// 元のスライスの0番目から(2-1)番目の要素をもつスライスを取得
fmt.Println(sl[:2]) // [1 2]と表示

そして、もちろんスライスの特定の要素に対して値を代入することもできます。

sl := []int{1, 2, 3, 4}
sl[2] = 100
fmt.Println(sl) // [1 2 100 4]と表示

また、別の変数に対して、既に宣言したスライスの参照を渡すことができます。

sl := []int{1, 2, 3, 4}

// sl2にslの参照を渡す
sl2 := sl

fmt.Println(sl) // [1 2 3 4]と表示
fmt.Println(sl2) // [1 2 3 4]と表示

// slの特定の要素を変更すると、同じ参照を持つsl2の方でも値が変わっているのを確認できる。
sl[2] = 100
fmt.Println(sl) // [1 2 100 4]と表示
fmt.Println(sl2) // [1 2 100 4]と表示

上記のように、別の変数に参照を渡すことで、同じ実体を複数の変数から見ることができます。

appendを用いたスライスの要素の追加

既に宣言済みのスライスに対して、要素を追加する際にはappend()関数を使って行います。 ここでは、len, capが4のスライスに対して、appendを行ってみます。

sl := []int{1, 2, 3, 4}

// sl: len 4  cap 4   slは [1 2 3 4]
fmt.Println("sl: len, %d cap %d", len(sl), cap(sl), sl)

// len,capが4で要素が満杯の状態でappendを行う
sl3 = append(sl, 5)
// sl: len 5  cap 8       sl は [1 2 3 4 5]
fmt.Printf("sl: len, %d cap %d", len(sl), cap(sl))

既に要素が満杯のスライスに対して appendを行うので、前述したとおり、新たにcapサイズを2倍の8にした上で、5を追加したスライスを返しています。
内部的にはmallocをしているので、append前と後では、変数slが指すポインタアドレスは別のものになっています。
スライスを使って実装をする上で、スライスのデータ構造、内部実装を理解していないと思わぬバグになりそうなので、ここは非常に注意が必要です。

Go deferについて

Goには、deferステートメントというものがあり、deferへ渡した関数実行を、その呼び出し元の関数の終了時(return)まで遅延させることができます。

package main

import "fmt"

func main() {
  // main関数の最後に実行される
  defer fmt.Println("world.")

  fmt.Println("hello.")
}

出力
hello. world

deferステートメントの特徴として、deferに関数を渡す際、引数の値は、deferに渡すタイミングで評価されます。
以下のようなコードを書いて実行してみると、その挙動がわかって面白いです

package main

import "fmt"

func main() {
  var i = 1

  // i = 1が fmt.Printlnに渡され、最後に実行される
  defer fmt.Println("world. %d", i)
  
  // i = 2
  i++
  //  i = 2が fmt.Printlnに渡され、最後に実行される
  defer fmt.Println("world2. %d", i)

  // i = 2が渡され、その場で実行される
  fmt.Println("hello %d", i)
}

出力
hello %d 2
world2. %d 2
world. %d 1

まとめると、deferに渡した関数は、引数はその場で評価され、実行は呼び出し元の関数の終了時に行われる。
ここの挙動について簡単ですが、しっかり理解していないと複雑なコードになったときにバグの元になるため注意が必要です。

Go 変数宣言についてのまとめ

仕事で Goを使う機会があったので、基本から勉強していて、変数宣言について気になった点についてメモがてらまとめます。

Golangにおける変数宣言において、 varをつかって変数宣言をするのが一般的

package main

import (
  "fmt"
)

func main() {

  // int型で宣言 初期値指定なし
  var i int

  // iとjをint型で宣言 初期値指定なし
  var i, j int

  // iとjをint型で宣言 初期値指定あり
  var i, j int = 1, 2

}

上記の方法だと変数毎に型を指定しましたが、 := をつかえば、型を指定せずに、暗黙的に変数宣言ができます。

package main

import "fmt"

func main() {
  var i, j int = 1, 2

  // := は暗黙の型変形
  k := 3
  c, python, java := true, false, "no !"

  // 1 2 3 true false no ! と表示される
  fmt.Println(i, j, k, c, python ,java)

  var i int = 1
  j := "1"  // string
  k := 1    // int

  // 変数型の確認
  fmt.Println(reflect.TypeOf(i))
  fmt.Println(reflect.TypeOf(j))
  fmt.Println(reflect.TypeOf(k))
}

ただし、:= をつかった暗黙的宣言は、基本的に関数スコープ内でのみの利用が可能で、関数宣言外では利用できません。

package main

import "fmt"

// 関数外での暗黙的宣言
l := 1
func main() {
  t := 1
  fmt.Println(t)
}

// コンパイルエラーが出る
$go run test-type.go
./test-type.go:7: syntax error: non-declaration statement outside function body

mongod replicaSet セカンダリノードを起動後に[RS102 too stale to catch up]が出たときの対処法

mongoでレプリカセットを組んでいて、たまに障害でセカンダリノードのサーバなどが死んで、
セカンダリノードのmongodプロセスを起動した後に、プライマリのデータをsyncできずに下記のエラーが出る場合があります

[rsBackgroundSync] replSet error RS102 too stale to catchup, at least from....

このエラーは、セカンダリノードが数時間とか長い間死んだ状態になっていて、syncの状態がstale(古い)な状態で再起動した場合に発生します。

原因

原因としては、セカンダリノードのoplogの情報が古すぎてsyncできないことが挙げられます。
mongoの仕組みとして、プライマリノードにデータが書き込まれるときのオペレーション情報をoplogに保持しています。
oplogコレクションは capped size collectionのため、一定サイズを超えた場合に古いものから消されていきます。 セカンダリノードはこのプライマリノードのoplogをtailしてsyncを行うのですが、 セカンダリノードが死んだままだと、
syncに必要なoplogの状態が更新されずに、プライマリのデータとsyncさせるためのデータが欠損してしまうために、このエラーが出ます。

対策

対策としては、セカンダリノードで持っているデータは古すぎて使えないため、一回まっさらな状態にした後にmongodを起動させると、初期状態からsyncが始まり、復旧できます

  • 問題が起きたセカンダリノードのmongodプロセスを停止する
  • セカンダリノードのデータディレクトリ(/data/mongodb とか、設定していたデータディレクトリ)を削除する。
  • 再度mongodをリセットする

これで再度初期状態からsyncが始まり、完了すると再びセカンダリノードとして復旧できます。

参考

stackoverflow.com

ソフトマックス関数についてのまとめ

前回の記事でニューラルネットワークの出力に対する活性化関数にシグモイド関数を使うことを紹介しました。 今回では、分類問題をニューラルネットワークをつかって解く際に、活性化関数としてよく使われるソフトマックス関数についてまとめます。

ソフトマックス関数とは

数式で表すと以下のとおりになります。

 {\large y_k = \frac {exp(a_k)} {\sum_{i=0}^n exp(a_i)}}

ソフトマックス関数の利点と特徴

ソフトマックス関数の特徴としては、大きく2つにわけられます。
- 出力の各要素の値域が  0 \leq y_i  \leq 1 となる
- 出力の総和が1となる

この2つのうち、2番目がニューラルネットワークで使う上で重要となります。 なぜならば、出力値の各要素の総和が1になるので、出力層の各ユニットの値を確率として解釈することができます。
これにより、問題に対して確率的アプローチを取れるようになります。

ソフトマックス関数の注意点

上記のように、ニューラルネットワークでソフトマックス関数を使うことで、出力層の各ユニットの値を確率として表現でき、統計的アプローチが取れる様になるのですが、
コンピュータ上でソフトマックス関数を使う際に注意点があります。
注意点は、ソフトマックス関数で  exp(a_i)を使うのですが、指数関数は入力値が増えると、出力の増加率も大きいので、値によっては桁数がたりずにオーバーフローしてしまいます。

どう解決するか

上記のオーバーフローの問題を解決するために、ソフトマックス関数の数式を、logと指数の性質を利用して変形していきます。

 {\large y_k = \frac {exp(a_k)} {\sum_{i=0}^n exp(a_i)}} (1)

分母と分子に定数Cをかけて
 {\large = \frac {Cexp(a_k)} {C \sum_{i=0}^n exp(a_i)}} (2)

 Cexp(x) = exp(x + logC) なので

 {\large = \frac {exp(a_k + logC)} {\sum_{i=0}^n exp(a_i + logC)}} (3)

 logC = C1として、さらに別の定数を用いて
 {\large = \frac {exp(a_k + C1)} {\sum_{i=0}^n exp(a_i + C1)}} (4)

と表すことができる。 ニューラルネットワークにおいては、上記の C1は入力信号の中で最大の値を用いるのが一般的です。
正負のどちらでも問題ないので、最大値を取得した上で、それを引くことでオーバーフローを回避できます。

import numpy as np
a = np.array([0.3, 2.9, 4.0])

def softmax_imp(a):
    # 入力値の中で最大値を取得
    c = np.max(a)
    # オーバーフロー対策として、最大値cを引く。こうすることで値が小さくなる
    exp_a = np.exp(a - c);
    sum_exp_a = np.sum(exp_a)

    y = exp_a / sum_exp_a
    return y

print(softmax_imp(a))

numpyを使えば、配列に対して最大値を求めたり、各要素に対して同じ処理を行うことが容易にできるので、かなり楽にかけます。