Skip to content

Commit b935441

Browse files
perf: add comprehensive benchmark suite with CI regression detection (#376)
* perf: add comprehensive benchmark suite with CI regression detection Add 96 benchmarks covering core driver hot paths: - Token parsing (10): ParseInfo, ParseDone, ParseReturnStatus, etc. - TDS buffer I/O (10): Read/Write in small/medium/large sizes - String encoding (10): Str2ucs2, Ucs22str, ManglePassword - Type conversion (13): ConvertAssign for all common type pairs - Wire protocol types (17): ReadFixedType, ReadByteLenType, etc. - RPC encoding (7): SendRpc with various parameter types - Bulk copy params (9): BulkMakeParam for common column types - Integration round-trips (14): Full E2E through SQL Server - Connection string parsing (5): URL and ADO format variants CI integration (.github/workflows/pr-validation.yml): - Runs benchmarks on every PR with SQL Server in Docker - Compares PR branch against main using benchstat - Uses -count=10 and -alpha=0.01 for statistical rigor - Fails CI on statistically significant regressions (p<0.01) - Reports improvements via GitHub Actions notices - Explicit benchmark pattern avoids slow pre-existing benchmarks - Copies benchmark files to main worktree for fair comparison Also fixes a pre-existing deadlock in BenchmarkSelectWithTypeMismatch where defer rows.Close() inside a loop with MaxOpenConns=1 caused connection starvation. * test: address PR feedback on benchmark suite - Extract common Parse benchmark loop into benchmarkParse helper - Add BenchmarkRoundTrip_MessageQuery covering the sqlexp message-based query loop
1 parent 687f9bd commit b935441

11 files changed

Lines changed: 1849 additions & 2 deletions

.github/workflows/pr-validation.yml

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,161 @@ jobs:
9191
else
9292
echo "SQL Server container 'sqlserver' was not found."
9393
fi
94+
95+
benchmarks:
96+
runs-on: ubuntu-latest
97+
timeout-minutes: 75
98+
permissions:
99+
contents: read
100+
pull-requests: write
101+
steps:
102+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
103+
with:
104+
fetch-depth: 0
105+
- name: Setup go
106+
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
107+
with:
108+
go-version: '1.25.7'
109+
- name: Install sqlcmd
110+
run: |
111+
curl -sSL https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/trusted.gpg.d/microsoft.asc
112+
curl -sSL https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list
113+
sudo apt-get update
114+
sudo ACCEPT_EULA=Y apt-get install -y mssql-tools18
115+
echo "/opt/mssql-tools18/bin" >> $GITHUB_PATH
116+
- name: Start SQL Server container
117+
shell: bash
118+
run: |
119+
export SQLCMDPASSWORD=$(date +%s|sha256sum|base64|head -c 32)
120+
echo "SQLCMDPASSWORD=$SQLCMDPASSWORD" >> $GITHUB_ENV
121+
docker run -m 2GB -e ACCEPT_EULA=1 -d --name sqlserver \
122+
-p 1433:1433 -e SA_PASSWORD=$SQLCMDPASSWORD \
123+
mcr.microsoft.com/mssql/server:2025-latest
124+
# Wait for SQL Server to be ready
125+
for i in {1..30}; do
126+
if sqlcmd -S localhost -U sa -P "$SQLCMDPASSWORD" -C -Q "SELECT 1" > /dev/null 2>&1; then
127+
echo "SQL Server is ready (attempt $i)"
128+
break
129+
fi
130+
echo "Waiting for SQL Server... (attempt $i/30)"
131+
sleep 2
132+
done
133+
- name: Warmup run (stabilize CPU/caches)
134+
shell: bash
135+
run: |
136+
export SQLUSER=sa
137+
export SQLPASSWORD=$SQLCMDPASSWORD
138+
export DATABASE=master
139+
export HOST=localhost
140+
export SQLSERVER_DSN="sqlserver://${SQLUSER}:${SQLPASSWORD}@localhost:1433?database=${DATABASE}&trustServerCertificate=true"
141+
BENCH_PATTERN='Benchmark(BulkMakeParam|ConvertAssign|Decode|Encode|ManglePassword|Parse|Read|RoundTrip|Send|Str2ucs2|TdsBuffer|Ucs22str|Write)'
142+
# Throwaway run to warm CPU caches, prime the Go runtime, and settle
143+
# the OS scheduler. Results are discarded — ensures both measurement
144+
# runs start from the same steady-state conditions.
145+
go test -run='^$' -bench="$BENCH_PATTERN" \
146+
-benchtime=100ms -count=1 -timeout=10m . ./msdsn > /dev/null 2>&1
147+
- name: Run baseline benchmarks (main)
148+
shell: bash
149+
run: |
150+
export SQLUSER=sa
151+
export SQLPASSWORD=$SQLCMDPASSWORD
152+
export DATABASE=master
153+
export HOST=localhost
154+
export SQLSERVER_DSN="sqlserver://${SQLUSER}:${SQLPASSWORD}@localhost:1433?database=${DATABASE}&trustServerCertificate=true"
155+
BENCH_PATTERN='Benchmark(BulkMakeParam|ConvertAssign|Decode|Encode|ManglePassword|Parse|Read|RoundTrip|Send|Str2ucs2|TdsBuffer|Ucs22str|Write)'
156+
git worktree add ../main-bench origin/main
157+
cp -v *_benchmark_test.go ../main-bench/ 2>/dev/null || true
158+
cp -v msdsn/*_benchmark_test.go ../main-bench/msdsn/ 2>/dev/null || true
159+
cd ../main-bench
160+
go test -run='^$' -bench="$BENCH_PATTERN" \
161+
-benchtime=1s -count=10 -benchmem -timeout=25m . ./msdsn 2>&1 | \
162+
tee "$GITHUB_WORKSPACE/bench_old_full.log"
163+
grep -E '^(Benchmark|goos:|goarch:|pkg:|cpu:)' "$GITHUB_WORKSPACE/bench_old_full.log" > "$GITHUB_WORKSPACE/bench_old.txt"
164+
cd "$GITHUB_WORKSPACE"
165+
git worktree remove ../main-bench --force
166+
- name: Run PR benchmarks
167+
shell: bash
168+
run: |
169+
export SQLUSER=sa
170+
export SQLPASSWORD=$SQLCMDPASSWORD
171+
export DATABASE=master
172+
export HOST=localhost
173+
export SQLSERVER_DSN="sqlserver://${SQLUSER}:${SQLPASSWORD}@localhost:1433?database=${DATABASE}&trustServerCertificate=true"
174+
BENCH_PATTERN='Benchmark(BulkMakeParam|ConvertAssign|Decode|Encode|ManglePassword|Parse|Read|RoundTrip|Send|Str2ucs2|TdsBuffer|Ucs22str|Write)'
175+
go test -run='^$' -bench="$BENCH_PATTERN" \
176+
-benchtime=1s -count=10 -benchmem -timeout=25m . ./msdsn 2>&1 | \
177+
tee bench_new_full.log
178+
grep -E '^(Benchmark|goos:|goarch:|pkg:|cpu:)' bench_new_full.log > bench_new.txt
179+
- name: Compare benchmarks
180+
shell: bash
181+
run: |
182+
go install golang.org/x/perf/cmd/benchstat@latest
183+
echo "## Benchmark Comparison (main vs PR)" >> "$GITHUB_STEP_SUMMARY"
184+
echo '```' >> "$GITHUB_STEP_SUMMARY"
185+
benchstat -alpha=0.01 bench_old.txt bench_new.txt | tee -a "$GITHUB_STEP_SUMMARY"
186+
echo '```' >> "$GITHUB_STEP_SUMMARY"
187+
benchstat -alpha=0.01 bench_old.txt bench_new.txt > bench_diff.txt
188+
- name: Check for regressions
189+
shell: bash
190+
run: |
191+
if [ ! -f bench_diff.txt ]; then
192+
echo "No comparison available, skipping regression check."
193+
exit 0
194+
fi
195+
# Report statistically significant improvements (real %, not ~)
196+
if grep -v '~' bench_diff.txt | grep -E '^\S+\s+.+\s+-[0-9]+\.[0-9]+%'; then
197+
echo ""
198+
echo "::notice::Performance improvements detected (see above)"
199+
fi
200+
# Fail on statistically significant regressions exceeding 15%
201+
# Sequential CI runs produce systematic drift up to ~12%, so we require
202+
# both statistical significance (no ~ marker) AND >15% magnitude.
203+
# Exclude TdsBuffer_Write_Large: ~120ns operation with multi-flush path
204+
# shows 30-46% swings between sequential CI runs due to cache sensitivity.
205+
REGRESSED=$(grep -v '~' bench_diff.txt | grep -v 'TdsBuffer_Write_Large' | grep -E '^\S+\s+.+\s+\+[0-9]+\.[0-9]+%' | awk -F'+' '{split($2,a,"%"); if (a[1]+0 >= 15) print}')
206+
if [ -n "$REGRESSED" ]; then
207+
echo "$REGRESSED"
208+
echo "::error::Statistically significant regression detected (>15%, p<0.01)"
209+
exit 1
210+
fi
211+
echo "No significant regressions detected."
212+
- name: Post benchmark results to PR
213+
# Fork PRs get read-only tokens; comment will be skipped gracefully.
214+
continue-on-error: true
215+
if: always() && github.event_name == 'pull_request'
216+
shell: bash
217+
env:
218+
GH_TOKEN: ${{ github.token }}
219+
run: |
220+
if [ ! -f bench_diff.txt ]; then
221+
echo "No benchmark comparison to post."
222+
exit 0
223+
fi
224+
BODY="## Benchmark Results (main vs PR)
225+
226+
<details>
227+
<summary>Click to expand benchstat output</summary>
228+
229+
\`\`\`
230+
$(cat bench_diff.txt)
231+
\`\`\`
232+
233+
</details>
234+
235+
*Generated by CI — commit $(git rev-parse --short HEAD)*"
236+
237+
# Remove leading whitespace from heredoc-style indentation
238+
BODY=$(echo "$BODY" | sed 's/^ //')
239+
240+
# Find and update existing benchmark comment, or create a new one
241+
COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
242+
--jq '.[] | select(.body | startswith("## Benchmark Results")) | .id' | head -1)
243+
244+
if [ -n "$COMMENT_ID" ]; then
245+
gh api "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" \
246+
-X PATCH -f body="$BODY"
247+
echo "Updated existing benchmark comment."
248+
else
249+
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY"
250+
echo "Posted new benchmark comment."
251+
fi

buf_benchmark_test.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package mssql
2+
3+
import (
4+
"io"
5+
"testing"
6+
)
7+
8+
// Benchmarks for TDS buffer operations — the core I/O layer for all packet framing.
9+
10+
// discardTransport implements io.ReadWriteCloser, discarding writes and providing zeros on read.
11+
type discardTransport struct{}
12+
13+
func (discardTransport) Read(p []byte) (int, error) { return len(p), nil }
14+
func (discardTransport) Write(p []byte) (int, error) { return len(p), nil }
15+
func (discardTransport) Close() error { return nil }
16+
17+
func BenchmarkTdsBuffer_Write_Small(b *testing.B) {
18+
buf := newTdsBuffer(4096, discardTransport{})
19+
payload := make([]byte, 64)
20+
for i := range payload {
21+
payload[i] = byte(i)
22+
}
23+
24+
b.SetBytes(int64(len(payload)))
25+
b.ResetTimer()
26+
for i := 0; i < b.N; i++ {
27+
buf.BeginPacket(packSQLBatch, false)
28+
buf.Write(payload)
29+
buf.FinishPacket()
30+
}
31+
}
32+
33+
func BenchmarkTdsBuffer_Write_Medium(b *testing.B) {
34+
buf := newTdsBuffer(4096, discardTransport{})
35+
payload := make([]byte, 1024)
36+
for i := range payload {
37+
payload[i] = byte(i % 256)
38+
}
39+
40+
b.SetBytes(int64(len(payload)))
41+
b.ResetTimer()
42+
for i := 0; i < b.N; i++ {
43+
buf.BeginPacket(packSQLBatch, false)
44+
buf.Write(payload)
45+
buf.FinishPacket()
46+
}
47+
}
48+
49+
func BenchmarkTdsBuffer_Write_Large(b *testing.B) {
50+
// Payload larger than packet size forces multiple flushes
51+
buf := newTdsBuffer(4096, discardTransport{})
52+
payload := make([]byte, 8192)
53+
for i := range payload {
54+
payload[i] = byte(i % 256)
55+
}
56+
57+
b.SetBytes(int64(len(payload)))
58+
b.ResetTimer()
59+
for i := 0; i < b.N; i++ {
60+
buf.BeginPacket(packSQLBatch, false)
61+
buf.Write(payload)
62+
buf.FinishPacket()
63+
}
64+
}
65+
66+
func BenchmarkTdsBuffer_WriteByte(b *testing.B) {
67+
buf := newTdsBuffer(4096, discardTransport{})
68+
69+
b.ResetTimer()
70+
for i := 0; i < b.N; i++ {
71+
buf.BeginPacket(packSQLBatch, false)
72+
for j := 0; j < 100; j++ {
73+
buf.WriteByte(byte(j))
74+
}
75+
buf.FinishPacket()
76+
}
77+
}
78+
79+
func BenchmarkTdsBuffer_Read_Small(b *testing.B) {
80+
// Simulate reading a small packet from transport
81+
packetSize := uint16(512)
82+
buf := newTdsBuffer(packetSize, nil)
83+
84+
// Pre-fill read buffer with a valid packet
85+
data := make([]byte, 64)
86+
for i := range data {
87+
data[i] = byte(i)
88+
}
89+
totalSize := 8 + len(data) // header + payload
90+
copy(buf.rbuf[8:], data)
91+
buf.rpos = 8
92+
buf.rsize = totalSize
93+
buf.final = true
94+
95+
dest := make([]byte, 64)
96+
b.SetBytes(int64(len(dest)))
97+
b.ResetTimer()
98+
for i := 0; i < b.N; i++ {
99+
buf.rpos = 8
100+
io.ReadFull(buf, dest)
101+
}
102+
}
103+
104+
func BenchmarkTdsBuffer_ReadByte(b *testing.B) {
105+
buf := newTdsBuffer(4096, nil)
106+
// Fill buffer with data
107+
for i := 0; i < 1000; i++ {
108+
buf.rbuf[i] = byte(i % 256)
109+
}
110+
buf.rpos = 0
111+
buf.rsize = 1000
112+
buf.final = true
113+
114+
b.ResetTimer()
115+
for i := 0; i < b.N; i++ {
116+
buf.rpos = 0
117+
for j := 0; j < 100; j++ {
118+
buf.ReadByte()
119+
}
120+
}
121+
}
122+
123+
func BenchmarkTdsBuffer_Uint16(b *testing.B) {
124+
buf := newTdsBuffer(4096, nil)
125+
// Fill with uint16 values
126+
for i := 0; i < 200; i += 2 {
127+
buf.rbuf[i] = byte(i)
128+
buf.rbuf[i+1] = byte(i >> 8)
129+
}
130+
buf.rpos = 0
131+
buf.rsize = 200
132+
buf.final = true
133+
134+
b.ResetTimer()
135+
for i := 0; i < b.N; i++ {
136+
buf.rpos = 0
137+
for j := 0; j < 50; j++ {
138+
buf.uint16()
139+
}
140+
}
141+
}
142+
143+
func BenchmarkTdsBuffer_Uint32(b *testing.B) {
144+
buf := newTdsBuffer(4096, nil)
145+
for i := 0; i < 400; i += 4 {
146+
buf.rbuf[i] = byte(i)
147+
buf.rbuf[i+1] = byte(i >> 8)
148+
buf.rbuf[i+2] = byte(i >> 16)
149+
buf.rbuf[i+3] = byte(i >> 24)
150+
}
151+
buf.rpos = 0
152+
buf.rsize = 400
153+
buf.final = true
154+
155+
b.ResetTimer()
156+
for i := 0; i < b.N; i++ {
157+
buf.rpos = 0
158+
for j := 0; j < 50; j++ {
159+
buf.uint32()
160+
}
161+
}
162+
}
163+
164+
func BenchmarkTdsBuffer_Uint64(b *testing.B) {
165+
buf := newTdsBuffer(4096, nil)
166+
for i := 0; i < 400; i++ {
167+
buf.rbuf[i] = byte(i % 256)
168+
}
169+
buf.rpos = 0
170+
buf.rsize = 400
171+
buf.final = true
172+
173+
b.ResetTimer()
174+
for i := 0; i < b.N; i++ {
175+
buf.rpos = 0
176+
for j := 0; j < 50; j++ {
177+
buf.uint64()
178+
}
179+
}
180+
}
181+
182+
func BenchmarkTdsBuffer_BeginFinishPacket(b *testing.B) {
183+
buf := newTdsBuffer(4096, discardTransport{})
184+
185+
b.ResetTimer()
186+
for i := 0; i < b.N; i++ {
187+
buf.BeginPacket(packSQLBatch, false)
188+
buf.FinishPacket()
189+
}
190+
}

0 commit comments

Comments
 (0)