Skip to content

Commit 27a6669

Browse files
committed
feat: add instrumentation to extract round times
1 parent 701b0b5 commit 27a6669

File tree

2 files changed

+132
-15
lines changed

2 files changed

+132
-15
lines changed

testing/testing/benchmark.go

Lines changed: 129 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@ package testing
66

77
import (
88
"context"
9+
"crypto/rand"
10+
"encoding/hex"
11+
"encoding/json"
912
"flag"
1013
"fmt"
1114
"io"
1215
"math"
1316
"os"
17+
"path/filepath"
1418
"runtime"
1519
"slices"
1620
"strconv"
@@ -138,26 +142,27 @@ type B struct {
138142
// a call to [B.StopTimer].
139143
func (b *B) StartTimer() {
140144
if !b.timerOn {
141-
runtime.ReadMemStats(&memStats)
142-
b.startAllocs = memStats.Mallocs
143-
b.startBytes = memStats.TotalAlloc
145+
// runtime.ReadMemStats(&memStats)
146+
// b.startAllocs = memStats.Mallocs
147+
// b.startBytes = memStats.TotalAlloc
144148
b.start = highPrecisionTimeNow()
145149
b.timerOn = true
146-
b.loop.i &^= loopPoisonTimer
150+
// b.loop.i &^= loopPoisonTimer
147151
}
148152
}
149153

150154
// StopTimer stops timing a test. This can be used to pause the timer
151155
// while performing steps that you don't want to measure.
152156
func (b *B) StopTimer() {
153157
if b.timerOn {
158+
b.codspeedTimePerRoundNs = append(b.codspeedTimePerRoundNs, highPrecisionTimeSince(b.start))
154159
b.duration += highPrecisionTimeSince(b.start)
155-
runtime.ReadMemStats(&memStats)
156-
b.netAllocs += memStats.Mallocs - b.startAllocs
157-
b.netBytes += memStats.TotalAlloc - b.startBytes
160+
// runtime.ReadMemStats(&memStats)
161+
// b.netAllocs += memStats.Mallocs - b.startAllocs
162+
// b.netBytes += memStats.TotalAlloc - b.startBytes
158163
b.timerOn = false
159164
// If we hit B.Loop with the timer stopped, fail.
160-
b.loop.i |= loopPoisonTimer
165+
// b.loop.i |= loopPoisonTimer
161166
}
162167
}
163168

@@ -222,6 +227,8 @@ func (b *B) runN(n int) {
222227
b.previousN = n
223228
b.previousDuration = b.duration
224229

230+
b.codspeedItersPerRound = append(b.codspeedItersPerRound, int64(n))
231+
225232
if b.loop.n > 0 && !b.loop.done && !b.failed {
226233
b.Error("benchmark function returned without B.Loop() == false (break or return in loop?)")
227234
}
@@ -275,6 +282,8 @@ var labelsOnce sync.Once
275282
// subbenchmarks. b must not have subbenchmarks.
276283
func (b *B) run() {
277284
labelsOnce.Do(func() {
285+
fmt.Fprintf(b.w, "Running with CodSpeed instrumentation\n")
286+
278287
fmt.Fprintf(b.w, "goos: %s\n", runtime.GOOS)
279288
fmt.Fprintf(b.w, "goarch: %s\n", runtime.GOARCH)
280289
if b.importPath != "" {
@@ -345,18 +354,36 @@ func (b *B) launch() {
345354
b.runN(b.benchTime.n)
346355
}
347356
} else {
348-
d := b.benchTime.d
349-
for n := int64(1); !b.failed && b.duration < d && n < 1e9; {
357+
warmupD := time.Millisecond * 500
358+
warmupN := int64(1)
359+
for n := int64(1); !b.failed && b.duration < warmupD && n < 1e9; {
350360
last := n
351361
// Predict required iterations.
352-
goalns := d.Nanoseconds()
362+
goalns := warmupD.Nanoseconds()
353363
prevIters := int64(b.N)
354364
n = int64(predictN(goalns, prevIters, b.duration.Nanoseconds(), last))
355365
b.runN(int(n))
366+
warmupN = n
367+
}
368+
369+
// Reset the fields from the warmup run
370+
b.codspeedItersPerRound = make([]int64, 0)
371+
b.codspeedTimePerRoundNs = make([]time.Duration, 0)
372+
373+
// Final run:
374+
benchD := time.Second * b.benchTime.d
375+
benchN := predictN(benchD.Nanoseconds(), int64(b.N), b.duration.Nanoseconds(), warmupN)
376+
rounds := 100 // TODO: Compute the rounds in a better way
377+
// Ensure roundN is at least 1 to prevent division by zero in BenchmarkResult printing
378+
// when very slow benchmarks can't complete even 1 iteration per round
379+
roundN := max(benchN/int(rounds), 1)
380+
381+
for range rounds {
382+
b.runN(int(roundN))
356383
}
357384
}
358385
}
359-
b.result = BenchmarkResult{b.N, b.duration, b.bytes, b.netAllocs, b.netBytes, b.extra}
386+
b.result = BenchmarkResult{b.N, b.duration, b.bytes, b.netAllocs, b.netBytes, b.codspeedTimePerRoundNs, b.codspeedItersPerRound, b.extra}
360387
}
361388

362389
// Elapsed returns the measured elapsed time of the benchmark.
@@ -409,14 +436,16 @@ func (b *B) stopOrScaleBLoop() bool {
409436
panic("loop iteration target overflow")
410437
}
411438
b.loop.i++
439+
440+
b.StartTimer()
412441
return true
413442
}
414443

415444
func (b *B) loopSlowPath() bool {
416445
// Consistency checks
417-
if !b.timerOn {
418-
b.Fatal("B.Loop called with timer stopped")
419-
}
446+
// if !b.timerOn {
447+
// b.Fatal("B.Loop called with timer stopped")
448+
// }
420449
if b.loop.i&loopPoisonMask != 0 {
421450
panic(fmt.Sprintf("unknown loop stop condition: %#x", b.loop.i))
422451
}
@@ -429,14 +458,21 @@ func (b *B) loopSlowPath() bool {
429458
// Within a b.Loop loop, we don't use b.N (to avoid confusion).
430459
b.N = 0
431460
b.loop.i++
461+
462+
b.codspeedItersPerRound = make([]int64, 0)
463+
b.codspeedTimePerRoundNs = make([]time.Duration, 0)
464+
432465
b.ResetTimer()
466+
b.StartTimer()
433467
return true
434468
}
435469
// Handles fixed iterations case
436470
if b.benchTime.n > 0 {
437471
if b.loop.n < uint64(b.benchTime.n) {
438472
b.loop.n = uint64(b.benchTime.n)
439473
b.loop.i++
474+
b.ResetTimer()
475+
b.StartTimer()
440476
return true
441477
}
442478
b.StopTimer()
@@ -483,6 +519,7 @@ func (b *B) loopSlowPath() bool {
483519
// whereas b.N-based benchmarks must run the benchmark function (and any
484520
// associated setup and cleanup) several times.
485521
func (b *B) Loop() bool {
522+
b.StopTimer()
486523
// This is written such that the fast path is as fast as possible and can be
487524
// inlined.
488525
//
@@ -497,6 +534,7 @@ func (b *B) Loop() bool {
497534
// path can do consistency checks and fail.
498535
if b.loop.i < b.loop.n {
499536
b.loop.i++
537+
b.StartTimer()
500538
return true
501539
}
502540
return b.loopSlowPath()
@@ -523,6 +561,9 @@ type BenchmarkResult struct {
523561
MemAllocs uint64 // The total number of memory allocations.
524562
MemBytes uint64 // The total number of bytes allocated.
525563

564+
CodspeedTimePerRoundNs []time.Duration
565+
CodspeedItersPerRound []int64
566+
526567
// Extra records additional metrics reported by ReportMetric.
527568
Extra map[string]float64
528569
}
@@ -754,6 +795,79 @@ func (s *benchState) processBench(b *B) {
754795
continue
755796
}
756797
results := r.String()
798+
799+
// ############################################################################################
800+
// START CODSPEED
801+
type RawResults struct {
802+
BenchmarkName string `json:"benchmark_name"`
803+
Pid int `json:"pid"`
804+
CodspeedTimePerRoundNs []time.Duration `json:"codspeed_time_per_round_ns"`
805+
CodspeedItersPerRound []int64 `json:"codspeed_iters_per_round"`
806+
}
807+
808+
// Build custom bench name with :: separator
809+
var nameParts []string
810+
current := &b.common
811+
for current.parent != nil {
812+
// Extract the sub-benchmark part by removing parent prefix
813+
parentName := current.parent.name
814+
if strings.HasPrefix(current.name, parentName+"/") {
815+
subName := strings.TrimPrefix(current.name, parentName+"/")
816+
nameParts = append([]string{subName}, nameParts...)
817+
} else {
818+
nameParts = append([]string{current.name}, nameParts...)
819+
}
820+
821+
if current.parent.name == "Main" {
822+
break
823+
}
824+
current = current.parent
825+
}
826+
customBenchName := strings.Join(nameParts, "::")
827+
828+
rawResults := RawResults{
829+
BenchmarkName: customBenchName,
830+
Pid: os.Getpid(),
831+
CodspeedTimePerRoundNs: r.CodspeedTimePerRoundNs,
832+
CodspeedItersPerRound: r.CodspeedItersPerRound,
833+
}
834+
835+
codspeedProfileFolder := os.Getenv("CODSPEED_PROFILE_FOLDER")
836+
if codspeedProfileFolder == "" {
837+
panic("CODSPEED_PROFILE_FOLDER environment variable is not set")
838+
}
839+
if err := os.MkdirAll(filepath.Join(codspeedProfileFolder, "raw_results"), 0755); err != nil {
840+
fmt.Fprintf(os.Stderr, "failed to create raw results directory: %v\n", err)
841+
continue
842+
}
843+
// Generate random filename to avoid any overwrites
844+
randomBytes := make([]byte, 16)
845+
if _, err := rand.Read(randomBytes); err != nil {
846+
fmt.Fprintf(os.Stderr, "failed to generate random filename: %v\n", err)
847+
continue
848+
}
849+
rawResultsFile := filepath.Join(codspeedProfileFolder, "raw_results", fmt.Sprintf("%s.json", hex.EncodeToString(randomBytes)))
850+
file, err := os.Create(rawResultsFile)
851+
if err != nil {
852+
fmt.Fprintf(os.Stderr, "failed to create raw results file: %v\n", err)
853+
continue
854+
}
855+
output, err := json.MarshalIndent(rawResults, "", " ")
856+
if err != nil {
857+
fmt.Fprintf(os.Stderr, "failed to marshal raw results: %v\n", err)
858+
file.Close()
859+
continue
860+
}
861+
// FIXME: Don't overwrite the file if it already exists
862+
if _, err := file.Write(output); err != nil {
863+
fmt.Fprintf(os.Stderr, "failed to write raw results: %v\n", err)
864+
file.Close()
865+
continue
866+
}
867+
defer file.Close()
868+
// END CODSPEED
869+
// ############################################################################################
870+
757871
if b.chatty != nil {
758872
fmt.Fprintf(b.w, "%-*s\t", s.maxLen, benchName)
759873
}

testing/testing/testing.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,9 @@ type common struct {
666666

667667
ctx context.Context
668668
cancelCtx context.CancelFunc
669+
670+
codspeedTimePerRoundNs []time.Duration
671+
codspeedItersPerRound []int64
669672
}
670673

671674
// Short reports whether the -test.short flag is set.

0 commit comments

Comments
 (0)