saliton / GoUint40

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Open In Colab

Go言語で整数を5バイトに押し込む簡単なお仕事

はじめに

ビッグデータを処理するような場合、データの読み込みが律速になることが多いです。その場合、データのストレージ上のサイズが小さくなると読み込み時間が減り、全体の処理時間が半分になったりします。そんな時、整数が40bitで十分ならば、40bitで保持したいですね。というわけで、Go言語で整数を40bitでストレージに読み書きする方法について調べました。

※ インデックス算出の際に時間計測に含めるべきではない演算を行っていたので、それを排除した内容に差し替えました。

まずは、Go言語をインストールします。

!wget https://golang.org/dl/go1.17.2.linux-amd64.tar.gz
!tar -C /usr/local -xzf go1.17.2.linux-amd64.tar.gz

import os
os.environ['PATH'] += ":/usr/local/go/bin"
--2022-01-20 02:05:24--  https://golang.org/dl/go1.17.2.linux-amd64.tar.gz
Resolving golang.org (golang.org)... 172.217.204.141, 2607:f8b0:400c:c15::8d
Connecting to golang.org (golang.org)|172.217.204.141|:443... connected.
HTTP request sent, awaiting response... 301 Moved Permanently
Location: https://go.dev/dl/go1.17.2.linux-amd64.tar.gz [following]
--2022-01-20 02:05:24--  https://go.dev/dl/go1.17.2.linux-amd64.tar.gz
Resolving go.dev (go.dev)... 216.239.32.21, 216.239.36.21, 216.239.38.21, ...
Connecting to go.dev (go.dev)|216.239.32.21|:443... connected.
HTTP request sent, awaiting response... 302 Found
Location: https://dl.google.com/go/go1.17.2.linux-amd64.tar.gz [following]
--2022-01-20 02:05:25--  https://dl.google.com/go/go1.17.2.linux-amd64.tar.gz
Resolving dl.google.com (dl.google.com)... 74.125.31.190, 74.125.31.91, 74.125.31.93, ...
Connecting to dl.google.com (dl.google.com)|74.125.31.190|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 134803982 (129M) [application/x-gzip]
Saving to: ‘go1.17.2.linux-amd64.tar.gz’

go1.17.2.linux-amd6 100%[===================>] 128.56M   230MB/s    in 0.6s    

2022-01-20 02:05:25 (230 MB/s) - ‘go1.17.2.linux-amd64.tar.gz’ saved [134803982/134803982]
!go version
go version go1.17.2 linux/amd64

64bit整数の8バイト配列化

unsafe.Pointerが最速

まずは64bitの整数をバイト配列にする方法を調べます。(以下では整数のエンコーディングはリトルエンディアンにします。特にunsafe.PointerがらみではビッグエンディアンのCPUでは間違った動作になりますのでご注意ください。)

愚直だとこうでしょうか。

for j := 0; j < 8; j += 1 {
    buf[j] = byte((v >> (8 * j)) & 0xFF)
}
%%writefile measure.go
package main

import (
	"fmt"
	"math/rand"
	"time"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	var vs [size]uint64
	for i := 0; i < size; i += 1 {
		vs[i] = uint64(rand.Intn(1<<40))
    }
	buf := make([][8]byte, size)

	// 計測開始
	start := time.Now()
	for i := 0; i < count/size; i += 1 {
		for idx := 0; idx < size; idx += 1 {
			v := vs[idx]
			b := &buf[idx]
			for j := 0; j < 8; j += 1 {
				b[j] = byte(v >> (8 * j))
			}
		}
	}
	fmt.Println(time.Since(start))
	// 計測終了

 	// 時間計測外の処理
    fmt.Println(buf[0])
	total := 0
	for i := 0; i < size; i += 1 {
		for j := 0; j < 8; j += 1 {
			  total += int(buf[i][j])
		}
	}
	fmt.Println(total) 
}
Writing measure.go
!go run measure.go
2m18.267307953s
[82 253 252 7 33 0 0 0]
63808521

流石にfor文で1バイトずつ処理していたら遅いです。

ちなみに、時間計測外の処理は、最適化による計測対象コードの削除防止と、処理の正しさの確認のために付与しています。

では定石通り、内側のfor文を展開しましょう。

%%writefile measure.go
package main

import (
	"fmt"
    "math/rand"
	"time"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	var vs [size]uint64
	for i := 0; i < size; i += 1 {
		vs[i] = uint64(rand.Intn(1<<40))
    }
	buf := make([][8]byte, size)

	// 計測開始
	start := time.Now()
	for i := 0; i < count/size; i += 1 {
        for idx := 0; idx < size; idx += 1 {
			v := vs[idx]
			b := &buf[idx]
			b[0] = byte(v)
			b[1] = byte(v >> 8)
			b[2] = byte(v >> 16)
			b[3] = byte(v >> 24)
			b[4] = byte(v >> 32)
			b[5] = byte(v >> 40)
			b[6] = byte(v >> 48)
			b[7] = byte(v >> 56)
		}
	}
	fmt.Println(time.Since(start))
	// 計測終了

 	// 時間計測外の処理
    fmt.Println(buf[0])
	total := 0
	for i := 0; i < size; i += 1 {
		for j := 0; j < 8; j += 1 {
			  total += int(buf[i][j])
		}
	}
	fmt.Println(total) 
}
Overwriting measure.go
!go run measure.go
39.898105281s
[82 253 252 7 33 0 0 0]
63808521

かなり高速化されました。まあ、for文で1バイトずつが遅すぎましたね。

次にライブラリを使いましょう。encoding/binaryライブラリにはエンコーディングを指定した上で整数をバイト配列に書き込む関数が用意されています。

%%writefile measure.go
package main

import (
	"encoding/binary"
	"fmt"
	"math/rand"
	"time"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	var vs [size]uint64
	for i := 0; i < size; i += 1 {
		vs[i] = uint64(rand.Intn(1<<40))
    }
	buf := make([][]byte, 0, size)
	for i := 0; i < size; i += 1 {
		buf = append(buf, make([]byte, 8))
    }

	// 計測開始
	start := time.Now()
	for i := 0; i < count/size; i += 1 {
		for idx := 0; idx < size; idx += 1 {
			binary.LittleEndian.PutUint64(buf[idx], vs[idx])
		}
	}
	fmt.Println(time.Since(start))
	// 計測終了

 	// 時間計測外の処理
    fmt.Println(buf[0])
	total := 0
	for i := 0; i < size; i += 1 {
		for j := 0; j < 8; j += 1 {
			  total += int(buf[i][j])
		}
	}
	fmt.Println(total) 
}
Overwriting measure.go
!go run measure.go
13.314782348s
[82 253 252 7 33 0 0 0]
63808521

速くなりました。

ここで禁断のunsafeが登場です。高速化のためにはunsafeは避けて通れません。

%%writefile measure.go
package main

import (
	"fmt"
	"time"
	"math/rand"
    "unsafe"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	var vs [size]uint64
	for i := 0; i < size; i += 1 {
		vs[i] = uint64(rand.Intn(1<<40))
    }
	var buf [size][8]byte

	// 計測開始
	start := time.Now()
	for i := 0; i < count/size; i += 1 {
		for idx := 0; idx < size; idx += 1 {
	        *(*uint64)(unsafe.Pointer(&buf[idx][0])) = vs[idx]
		}
	}
	fmt.Println(time.Since(start))
	// 計測終了
 
 	// 時間計測外の処理
    fmt.Println(buf[0])
	total := 0
	for i := 0; i < size; i += 1 {
		for j := 0; j < 8; j += 1 {
			  total += int(buf[i][j])
		}
	}
	fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
7.997059849s
[82 253 252 7 33 0 0 0]
63808521

さらに速くなりました。

8バイト配列から64bit整数への変換

binary.LittleEndian.Uint64()がお勧め

8バイトの配列をリトルエンディアンとして64bitの整数に変換する素朴な実装は以下になります。

%%writefile measure.go
package main

import (
	"fmt"
	"math/rand"
	"time"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	buf := make([][]byte, size)
    for i := 0; i < size; i += 1 {
        buf[i] = []byte{byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256))}
    }

    // 計測開始
 	start := time.Now()
 	total := uint64(0)
	for i := 0; i < count/size; i += 1 {
        for idx := 0; idx < size; idx += 1 {
            b := buf[idx]
            v := uint64(b[0])
            for j := 1; j < 8; j += 1 {
                v += uint64(b[j]) << (8 * j)
            }
            total += v
        }
	}
	fmt.Println(time.Since(start))
    // 計測終了
 
    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
2m52.441620364s
14225278786399272160

次に内側のfor文を展開したもの。

%%writefile measure.go
package main

import (
	"fmt"
	"math/rand"
	"time"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	buf := make([][]byte, size)
    for i := 0; i < size; i += 1 {
        buf[i] = []byte{byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256))}
    }

    // 計測開始
 	start := time.Now()
 	total := uint64(0)
	for i := 0; i < count/size; i += 1 {
        for idx := 0; idx < size; idx += 1 {
            b := buf[idx]
            v := uint64(b[0])
            v += uint64(b[1]) << 8
            v += uint64(b[2]) << 16
            v += uint64(b[3]) << 24
            v += uint64(b[4]) << 32
            v += uint64(b[5]) << 40
            v += uint64(b[6]) << 48
            v += uint64(b[7]) << 56
            total += v
        }
	}
	fmt.Println(time.Since(start))
    // 計測終了
 
    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
39.030929653s
14225278786399272160

それではライブラリを使ってみましょう。

%%writefile measure.go
package main

import (
	"encoding/binary"
	"fmt"
    "math/rand"
	"time"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	buf := make([][]byte, size)
    for i := 0; i < size; i += 1 {
        buf[i] = []byte{byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256))}
    }

    // 計測開始
	start := time.Now()
    total := uint64(0)
	for i := 0; i < count/size; i += 1 {
        for idx := 0; idx < size; idx += 1 {
    		total += binary.LittleEndian.Uint64(buf[idx])
        }
	}
	fmt.Println(time.Since(start))
    // 計測終了

    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
12.682570622s
14225278786399272160

相当速くなりました。

次はunsafe.Pointer()による実装。

%%writefile measure.go
package main

import (
	"fmt"
	"math/rand"
	"time"
    "unsafe"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	buf := make([][]byte, size)
    for i := 0; i < size; i += 1 {
        buf[i] = []byte{byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256))}
    }

    // 計測開始
	start := time.Now()
    total := uint64(0)
	for i := 0; i < count/size; i += 1 {
        for idx := 0; idx < size; idx += 1 {
    		total += *(*uint64)(unsafe.Pointer(&buf[idx][0]))
        }
	}
	fmt.Println(time.Since(start))
    // 計測終了

    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
12.853978123s
14225278786399272160

ほぼ同じです。ただ、CPUがリトルエンディアンである必要があることに注意してください。

40bit整数の5バイト配列化

for文を展開するのがおすすめ

次に40bit(5バイト)を考えましょう。64bitのデータから40bitを取り出す処理が必要です。

まずは素朴な実装。

%%writefile measure.go
package main

import (
	"fmt"
	"math/rand"
	"time"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	var vs [size]uint64
	for i := 0; i < size; i += 1 {
		vs[i] = uint64(rand.Intn(1<<40))
    }

	var buf [size][5]byte

	// 計測開始
	start := time.Now()
	for i := 0; i < count/size; i += 1 {
		for idx := 0; idx < size; idx += 1 {
			v := vs[idx]
			b := &buf[idx]
            for j := 0; j < 5; j += 1 {
    			b[j] = byte(v >> (8 * j))
            }
		}
	}
	fmt.Println(time.Since(start))
	// 計測終了
 
 	// 時間計測外の処理
    fmt.Println(buf[0])
	total := 0
	for i := 0; i < size; i += 1 {
		for j := 0; j < 5; j += 1 {
			  total += int(buf[i][j])
		}
	}
	fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
1m30.425536975s
[82 253 252 7 33]
63808521

ちょうど8回のループが5回になった分だけ処理時間が短くなっています。

次に内側のfor文を展開したもの。

%%writefile measure.go
package main

import (
	"fmt"
	"math/rand"
	"time"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	var vs [size]uint64
	for i := 0; i < size; i += 1 {
		vs[i] = uint64(rand.Intn(1<<40))
    }

	var buf [size][5]byte

	// 計測開始
	start := time.Now()
	for i := 0; i < count/size; i += 1 {
		for idx := 0; idx < size; idx += 1 {
			v := vs[idx]
			b := &buf[idx]
			b[0] = byte(v)
			b[1] = byte(v >> 8)
			b[2] = byte(v >> 16)
			b[3] = byte(v >> 24)
			b[4] = byte(v >> 32)
		}
	}
	fmt.Println(time.Since(start))
	// 計測終了
 
 	// 時間計測外の処理
    fmt.Println(buf[0])
	total := 0
	for i := 0; i < size; i += 1 {
		for j := 0; j < 5; j += 1 {
			  total += int(buf[i][j])
		}
	}
	fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
10.798947945s
[82 253 252 7 33]
63808521

処理時間は64bit整数の時の8分の5よりだいぶ短いです。原因はよくわかりませんが、格納するバッファの大きさが小さくなったのが影響している可能性があります。

次はライブラリ使用です。PutUint64()は引数に8バイト以上のスライスしかとれないので、copy(dst, src)を使って8バイトに書き込んだworkから5バイト分コピーします。

%%writefile measure.go
package main

import (
	"encoding/binary"
	"fmt"
	"math/rand"
	"time"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	var vs [size]uint64
	var buf [size][]byte
	for i := 0; i < size; i += 1 {
		vs[i] = uint64(rand.Intn(1<<40))
		buf[i] = make([]byte, 5)
    }

	work := make([]byte, 8)

	// 計測開始
	start := time.Now()
	for i := 0; i < count / size; i += 1 {
		for idx := 0; idx < size; idx += 1 {
			binary.LittleEndian.PutUint64(work, vs[idx])
			copy(buf[idx], work[:5])
		}
	}
	fmt.Println(time.Since(start))
	// 計測終了
 
 	// 時間計測外の処理
    fmt.Println(buf[0])
	total := 0
	for i := 0; i < size; i += 1 {
		for j := 0; j < 5; j += 1 {
			  total += int(buf[i][j])
		}
	}
	fmt.Println(total)
 }
Overwriting measure.go
!go run measure.go
47.237593761s
[82 253 252 7 33]
63808521

遅くなりました。やはり複製は重たい。

unsafe.Pointer()ではどうでしょうか。

%%writefile measure.go
package main

import (
	"fmt"
	"time"
	"math/rand"
    "unsafe"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	var vs [size]uint64
	var buf [size][]byte
	for i := 0; i < size; i += 1 {
		vs[i] = uint64(rand.Intn(1<<40))
		buf[i] = make([]byte, 5)
    }

	var work [8]byte
 
	// 計測開始
	start := time.Now()
	for i := 0; i < count/size; i += 1 {
		for idx := 0; idx < size; idx += 1 {
			*(*uint64)(unsafe.Pointer(&work[0])) = vs[idx]
			copy(buf[idx], work[:5])
		}
	}
	fmt.Println(time.Since(start))
	// 計測終了
 
 	// 時間計測外の処理
    fmt.Println(buf[0])
	total := 0
	for i := 0; i < size; i += 1 {
		for j := 0; j < 5; j += 1 {
			  total += int(buf[i][j])
		}
	}
	fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
48.494321196s
[82 253 252 7 33]
63808521

ライブラリ使用と同じです。複製の処理が律速になっているようです。

複製はせずに、読み込む時に5バイトだけ取り出すようにしたらどうでしょうか。

%%writefile measure.go
package main

import (
	"fmt"
	"time"
	"math/rand"
    "unsafe"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	var vs [size]uint64
	for i := 0; i < size; i += 1 {
		vs[i] = uint64(rand.Intn(1<<40))
    }

	var buf [8]byte
	total := uint64(0)

	// 計測開始
	start := time.Now()
	for i := 0; i < count/size; i += 1 {
		for idx := 0; idx < size; idx += 1 {
			*(*uint64)(unsafe.Pointer(&buf[0])) = vs[idx]
	        total += *(*uint64)(unsafe.Pointer(&buf[0])) & 0xFF_FFFF_FFFF
		}
	}
	fmt.Println(time.Since(start))
	// 計測終了
 
    fmt.Println(buf, total)
}
Overwriting measure.go
!go run measure.go
12.294887158s
[25 237 192 70 183 0 0 0] 5692317074739218432

だいぶ速くなりましたが、for文の展開には及びません。

0xFF_FFFF_FFFFのマスクを工夫してみます。

%%writefile measure.go
package main

import (
	"fmt"
	"time"
	"math/rand"
    "unsafe"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	var vs [size]uint64
	for i := 0; i < size; i += 1 {
		vs[i] = uint64(rand.Intn(1<<40))
    }

	var buf [8]byte
	total := uint64(0)

	// 計測開始
	start := time.Now()
	for i := 0; i < count/size; i += 1 {
		for idx := 0; idx < size; idx += 1 {
			*(*uint64)(unsafe.Pointer(&buf[0])) = vs[idx]
	        total += uint64(*(*uint32)(unsafe.Pointer(&buf[0]))) + uint64(buf[4]) << 32
		}
	}
	fmt.Println(time.Since(start))
	// 計測終了
 
    fmt.Println(buf, total)
}
Overwriting measure.go
!go run measure.go
12.246566138s
[25 237 192 70 183 0 0 0] 5692317074739218432

ほぼ同じです。

5バイト配列から40bit整数への変換

v = uint64(binary.LittleEndian.Uint32(buf)) + uint64(buf[4]) << 32がおすすめ

5バイトの配列を40bitの整数として読み出します。まずは素朴な実装から。

%%writefile measure.go
package main

import (
	"fmt"
	"math/rand"
	"time"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	var buf [size][]byte
    for i := 0; i < size; i += 1 {
        buf[i] = []byte{byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256))}
    }

    // 計測開始
 	start := time.Now()
 	total := uint64(0)
	for i := 0; i < count/size; i += 1 {
        for idx := 0; idx < size; idx += 1 {
            b := buf[idx]
            v := uint64(0)
            for j := 0; j < 5; j += 1 {
                v += uint64(b[j]) << (8 * j)
            }
            total += v
        }
	}
	fmt.Println(time.Since(start))
    // 計測終了
 
    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
1m39.081932045s
14634206628677970048

素朴な実装では整数から配列に分配するのと同等の時間がかかっています。

次に内側のfor文の展開

%%writefile measure.go
package main

import (
	"fmt"
	"math/rand"
	"time"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	var buf [size][]byte
    for i := 0; i < size; i += 1 {
        buf[i] = []byte{byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256))}
    }

    // 計測開始
 	start := time.Now()
 	total := uint64(0)
	for i := 0; i < count/size; i += 1 {
        for idx := 0; idx < size; idx += 1 {
            b := buf[idx]
            v := uint64(b[0])
            v += uint64(b[1]) << 8
            v += uint64(b[2]) << 16
            v += uint64(b[3]) << 24
            v += uint64(b[4]) << 32
            total += v
        }
	}
	fmt.Println(time.Since(start))
    // 計測終了
 
    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
23.83489207s
14634206628677970048

この場合は、配列から整数に戻す方が時間がかかるようです。

ライブラリ使用ではどうでしょうか。

%%writefile measure.go
package main

import (
	"encoding/binary"
	"fmt"
	"math/rand"
	"time"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	var buf [size][]byte
    for i := 0; i < size; i += 1 {
        buf[i] = []byte{byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)), 0, 0, 0}
    }

	// 計測開始
	start := time.Now()
 	total := uint64(0)
	for i := 0; i < count/size; i += 1 {
		for idx := 0; idx < size; idx += 1 {
			total += binary.LittleEndian.Uint64(buf[idx]) & 0xFF_FFFF_FFFF
		}
	}
	fmt.Println(time.Since(start))
	// 計測終了
 
    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
13.522700543s
14634206628677970048

改善しました。ただ、ライブラリを利用するために、配列を5バイトではなく8バイトに水増しするズルをしています。

0xFF_FFFF_FFFFでマスクする処理を変更してみましょう。配列の前半4バイトを32bitの整数として、5バイト目を32ビットずらして加算することでマスク処理を回避します。

%%writefile measure.go
package main

import (
	"encoding/binary"
	"fmt"
	"math/rand"
	"time"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	var buf [size][]byte
    for i := 0; i < size; i += 1 {
        buf[i] = []byte{byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256))}
    }

	// 計測開始
	start := time.Now()
 	total := uint64(0)
	for i := 0; i < count/size; i += 1 {
		for idx := 0; idx < size; idx += 1 {
			b := buf[idx]
			total += uint64(binary.LittleEndian.Uint32(b)) + (uint64(b[4]) << 32)
		}
	}
	fmt.Println(time.Since(start))
	// 計測終了
 
    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
14.439393482s
14634206628677970048

少し遅くなりました。ただ、こちらは8バイトへの水増しが不要です。

unsafe.Pointer()を使った場合はどうでしょうか。

%%writefile measure.go
package main

import (
	"fmt"
	"math/rand"
	"time"
    "unsafe"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
	var buf [size][]byte
    for i := 0; i < size; i += 1 {
        buf[i] = []byte{byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)),
                        byte(rand.Intn(256)), 0, 0, 0}
    }

	// 計測開始
 	start := time.Now()
 	total := uint64(0)
	for i := 0; i < count/size; i += 1 {
		for idx := 0; idx < size; idx += 1 {
			total += *(*uint64)(unsafe.Pointer(&buf[idx][0])) & 0xFF_FFFF_FFFF
		}
	}
	fmt.Println(time.Since(start))
	// 計測終了
 
    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
13.480186306s
14634206628677970048

ライブラリと同程度です。CPUがリトルエンディアンである必要もあることですし、わざわざunsafe.Pointer()を使う意味はないようです。

40bit整数の配列の5Nバイト配列化

*(*uint64)(unsafe.Pointer(&buf[j])) = vがおすすめ

次に整数の配列をバイト配列化する場合を測定しましょう。10万個の整数を5バイトずつのバイト配列にします。

1バイトずつ書き写す素朴な処理は以下になります。

%%writefile measure.go
package main

import (
	"fmt"
	"math/rand"
	"time"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
    var ans [size]uint64
    for i := 0; i < size; i += 1 {
    	ans[i] = uint64(rand.Intn(1<<40))
    }
	var buf [size * 5]byte
 
    // 計測開始
	start := time.Now()
	for i := 0; i < count/size; i += 1 {
        for idx := 0; idx < size; idx += 1 {
            v := ans[idx]
            b := buf[(idx*5):(idx*5+5)]
            for j := 0; j < 5; j += 1 {
                b[j] = byte(v >> (8 * j))
            }
        }
	}
	fmt.Println(time.Since(start))
    // 計測終了
 
    total := uint64(0)
    for i := 0; i < size * 5; i += 1 {
        total += uint64(buf[i])
    }
    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
1m43.655985153s
63808521

内側のfor文を展開した場合

%%writefile measure.go
package main

import (
	"fmt"
	"math/rand"
	"time"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
    var ans [size]uint64
    for i := 0; i < size; i += 1 {
    	ans[i] = uint64(rand.Intn(1<<40))
    }
	var buf [size * 5]byte
 
    // 計測開始
	start := time.Now()
	for i := 0; i < count/size; i += 1 {
        for idx := 0; idx < size; idx += 1 {
            v := ans[idx]
            b := buf[(idx*5):(idx*5+5)]
            b[0] = byte(v)
            b[1] = byte(v >> 8)
            b[2] = byte(v >> 16)
            b[3] = byte(v >> 24)
            b[4] = byte(v >> 32)
        }
	}
	fmt.Println(time.Since(start))
    // 計測終了
 
    total := uint64(0)
    for i := 0; i < size * 5; i += 1 {
        total += uint64(buf[i])
    }
    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
15.917946557s
63808521

Goの場合は、for文の展開が効きますね。この最適化は自動的にはなされないようです。

Go言語には上記のように1バイトずつ書き込むほかには、4バイトずつ、あるいは、8バイトずつ書き込むモジュールがあります。binary.LittleEndian.PutUint32()binary.LittleEndian.PutUint64()です。これらはCPUがネイティブで複製命令を持つサイズであるため、高速に実行されます。しかし、5バイトを書き込むものはありません。そこでリトルエンディアンの並びを利用して、以下のように5バイトずらしながら8バイトずつ書き込むことで所望の結果を得ます。

bytes.png

以下がそのコードです。

%%writefile measure.go
package main

import (
    "encoding/binary"
	"fmt"
	"math/rand"
	"time"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
    var ans [size]uint64
    for i := 0; i < size; i += 1 {
    	ans[i] = uint64(rand.Intn(1<<40))
    }
	var buf [size * 5 + 3]byte
 
    // 計測開始
	start := time.Now()
	for i := 0; i < count/size; i += 1 {
        for idx := 0; idx < size; idx += 1 {
            binary.LittleEndian.PutUint64(buf[idx*5:], ans[idx])
        }
	}
	fmt.Println(time.Since(start))
    // 計測終了
 
    total := uint64(0)
    for i := 0; i < size * 5; i += 1 {
        total += uint64(buf[i])
    }
    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
15.710748546s
63808521

for文の展開とほぼ同じ速度です。

次はunsafe.Pointer()で実装します。

%%writefile measure.go
package main

import (
	"fmt"
	"math/rand"
	"time"
    "unsafe"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
    var ans [size]uint64
    for i := 0; i < size; i += 1 {
    	ans[i] = uint64(rand.Intn(1<<40))
    }
	var buf [size * 5 + 3]byte

    // 計測開始
	start := time.Now()
	for i := 0; i < count/size; i += 1 {
        for idx := 0; idx < size; idx += 1 {
            *(*uint64)(unsafe.Pointer(&buf[idx*5])) = ans[idx]
        }
	}
	fmt.Println(time.Since(start))
    // 計測終了
 
    total := uint64(0)
    for i := 0; i < size * 5; i += 1 {
        total += uint64(buf[i])
    }
    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
11.727558834s
63808521

いよいよ本領を発揮しました。速いです。

5Nバイト配列から40bit整数の配列への変換

v = uint64(*(*uint32)(unsafe.Pointer(&buf[j]))) + uint64(buf[j+4]) << 32がおすすめ

40bit整数が並んだバイト配列から整数を読み出す場合を測定しましょう。5バイトずつのバイト配列から10万個の整数を読み出します。素朴な実装は以下になります。

%%writefile measure.go
package main

import (
	"fmt"
	"math/rand"
	"time"
    "unsafe"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
    var ans [size]uint64
    for i := 0; i < size; i += 1 {
    	ans[i] = uint64(rand.Intn(1<<40))
    }
	var buf [size * 5]byte
 
    for j := 0; j < size; j += 1 {
        v := ans[j]
        *(*uint64)(unsafe.Pointer(&buf[j*5])) = v
    }

    // 計測開始
	start := time.Now()
    total := uint64(0)
    for i := 0; i < count/size; i += 1 {
        for idx := 0; idx < size * 5; idx += 5 {
            b := buf[idx:idx+5]
            v := uint64(b[0])
            v += uint64(b[1]) << 8
            v += uint64(b[2]) << 16
            v += uint64(b[3]) << 24
            v += uint64(b[4]) << 32
            total += v
        }
	}
	fmt.Println(time.Since(start))
    // 計測終了
 
    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
23.48296862s
5692317074739218432

これが基準になります。

次にライブラリ使用で、5バイトのマスク。

%%writefile measure.go
package main

import (
    "encoding/binary"
	"fmt"
	"math/rand"
	"time"
    "unsafe"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
    var ans [size]uint64
    for i := 0; i < size; i += 1 {
    	ans[i] = uint64(rand.Intn(1<<40))
    }
	var buf [size * 5 + 3]byte
 
    for j := 0; j < size; j += 1 {
        v := ans[j]
        *(*uint64)(unsafe.Pointer(&buf[j*5])) = v
    }

    // 計測開始
	start := time.Now()
    total := uint64(0)
    for i := 0; i < count/size; i += 1 {
        for idx := 0; idx < size * 5; idx += 5 {
            total += binary.LittleEndian.Uint64(buf[idx:]) & 0xFF_FFFF_FFFF
        }
	}
	fmt.Println(time.Since(start))
    // 計測終了
 
    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
19.391102667s
5692317074739218432

少し改善。ただ、bufの領域を3バイト増やしています。

次はマスク外し。

%%writefile measure.go
package main

import (
    "encoding/binary"
	"fmt"
	"math/rand"
	"time"
    "unsafe"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
    var ans [size]uint64
    for i := 0; i < size; i += 1 {
    	ans[i] = uint64(rand.Intn(1<<40))
    }
	var buf [size * 5]byte
 
    for j := 0; j < size; j += 1 {
        v := ans[j]
        *(*uint64)(unsafe.Pointer(&buf[j*5])) = v
    }

    // 計測開始
	start := time.Now()
    total := uint64(0)
	for i := 0; i < count/size; i += 1 {
        for idx := 0; idx < size * 5; idx += 5 {
            total += uint64(binary.LittleEndian.Uint32(buf[idx:])) + uint64(buf[idx+4]) << 32
        }
	}
	fmt.Println(time.Since(start))
    // 計測終了

    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
19.469417185s
5692317074739218432

ほぼ同じ。ただ、bufの領域はsize*5です。

次はunsafe.Pointer()かつ5バイトマスク。

%%writefile measure.go
package main

import (
	"fmt"
	"math/rand"
	"time"
    "unsafe"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
    var ans [size]uint64
    for i := 0; i < size; i += 1 {
    	ans[i] = uint64(rand.Intn(1<<40))
    }
	var buf [size * 5 + 3]byte
 
    for j := 0; j < size; j += 1 {
        v := ans[j]
        *(*uint64)(unsafe.Pointer(&buf[j*5])) = v
    }

    // 計測開始
	start := time.Now()
    total := uint64(0)
	for i := 0; i < count/size; i += 1 {
        for idx := 0; idx < size * 5; idx += 5 {
            total += *(*uint64)(unsafe.Pointer(&buf[idx])) & 0xFF_FFFF_FFFF
        }
	}
	fmt.Println(time.Since(start))
    // 計測終了

    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
11.761672529s
5692317074739218432

改善。ただ、bufは3バイト追加しています。

最後にマスク外し。

%%writefile measure.go
package main

import (
	"fmt"
	"math/rand"
	"time"
    "unsafe"
)

func main() {
	const count = 10_000_000_000
    const size = 100_000
    var ans [size]uint64
    for i := 0; i < size; i += 1 {
    	ans[i] = uint64(rand.Intn(1<<40))
    }
	var buf [size * 5]byte
 
    for j := 0; j < size; j += 1 {
        v := ans[j]
        *(*uint64)(unsafe.Pointer(&buf[j*5])) = v
    }

    // 計測開始
	start := time.Now()
    total := uint64(0)
	for i := 0; i < count/size; i += 1 {
        for idx := 0; idx < size * 5; idx += 5 {
            total += uint64(*(*uint32)(unsafe.Pointer(&buf[idx]))) + uint64(buf[idx+4]) << 32
        }
	}
	fmt.Println(time.Since(start))
    // 計測終了

    fmt.Println(total)
}
Overwriting measure.go
!go run measure.go
11.717058394s
5692317074739218432

速度はほぼ同じ。ただ、bufのサイズはsize*5です。

結局、大きな配列に連続して40bit整数を書き込んだり読み込んだりするのにはunsafe.Pointer()が有効でした。

About

License:MIT License


Languages

Language:Jupyter Notebook 100.0%