...

Source file src/github.com/onsi/gomega/gmeasure/experiment.go

Documentation: github.com/onsi/gomega/gmeasure

     1  /*
     2  Package gomega/gmeasure provides support for benchmarking and measuring code.  It is intended as a more robust replacement for Ginkgo V1's Measure nodes.
     3  
     4  gmeasure is organized around the metaphor of an Experiment that can record multiple Measurements.  A Measurement is a named collection of data points and gmeasure supports
     5  measuring Values (of type float64) and Durations (of type time.Duration).
     6  
     7  Experiments allows the user to record Measurements directly by passing in Values (i.e. float64) or Durations (i.e. time.Duration)
     8  or to measure measurements by passing in functions to measure.  When measuring functions Experiments take care of timing the duration of functions (for Duration measurements)
     9  and/or recording returned values (for Value measurements).  Experiments also support sampling functions - when told to sample Experiments will run functions repeatedly
    10  and measure and record results.  The sampling behavior is configured by passing in a SamplingConfig that can control the maximum number of samples, the maximum duration for sampling (or both)
    11  and the number of concurrent samples to take.
    12  
    13  Measurements can be decorated with additional information.  This is supported by passing in special typed decorators when recording measurements.  These include:
    14  
    15  - Units("any string") - to attach units to a Value Measurement (Duration Measurements always have units of "duration")
    16  - Style("any Ginkgo color style string") - to attach styling to a Measurement.  This styling is used when rendering console information about the measurement in reports.  Color style strings are documented at TODO.
    17  - Precision(integer or time.Duration) - to attach precision to a Measurement.  This controls how many decimal places to show for Value Measurements and how to round Duration Measurements when rendering them to screen.
    18  
    19  In addition, individual data points in a Measurement can be annotated with an Annotation("any string").  The annotation is associated with the individual data point and is intended to convey additional context about the data point.
    20  
    21  Once measurements are complete, an Experiment can generate a comprehensive report by calling its String() or ColorableString() method.
    22  
    23  Users can also access and analyze the resulting Measurements directly.  Use Experiment.Get(NAME) to fetch the Measurement named NAME.  This returned struct will have fields containing
    24  all the data points and annotations recorded by the experiment.  You can subsequently fetch the Measurement.Stats() to get a Stats struct that contains basic statistical information about the
    25  Measurement (min, max, median, mean, standard deviation).  You can order these Stats objects using RankStats() to identify best/worst performers across multpile experiments or measurements.
    26  
    27  gmeasure also supports caching Experiments via an ExperimentCache.  The cache supports storing and retreiving experiments by name and version.  This allows you to rerun code without
    28  repeating expensive experiments that may not have changed (which can be controlled by the cache version number).  It also enables you to compare new experiment runs with older runs to detect
    29  variations in performance/behavior.
    30  
    31  When used with Ginkgo, you can emit experiment reports and encode them in test reports easily using Ginkgo V2's support for Report Entries.
    32  Simply pass your experiment to AddReportEntry to get a report every time the tests run.  You can also use AddReportEntry with Measurements to emit all the captured data
    33  and Rankings to emit measurement summaries in rank order.
    34  
    35  Finally, Experiments provide an additional mechanism to measure durations called a Stopwatch.  The Stopwatch makes it easy to pepper code with statements that measure elapsed time across
    36  different sections of code and can be useful when debugging or evaluating bottlenecks in a given codepath.
    37  */
    38  package gmeasure
    39  
    40  import (
    41  	"fmt"
    42  	"math"
    43  	"reflect"
    44  	"sync"
    45  	"time"
    46  
    47  	"github.com/onsi/gomega/gmeasure/table"
    48  )
    49  
    50  /*
    51  SamplingConfig configures the Sample family of experiment methods.
    52  These methods invoke passed-in functions repeatedly to sample and record a given measurement.
    53  SamplingConfig is used to control the maximum number of samples or time spent sampling (or both).  When both are specified sampling ends as soon as one of the conditions is met.
    54  SamplingConfig can also ensure a minimum interval between samples and can enable concurrent sampling.
    55  */
    56  type SamplingConfig struct {
    57  	// N - the maximum number of samples to record
    58  	N int
    59  	// Duration - the maximum amount of time to spend recording samples
    60  	Duration time.Duration
    61  	// MinSamplingInterval - the minimum time that must elapse between samplings.  It is an error to specify both MinSamplingInterval and NumParallel.
    62  	MinSamplingInterval time.Duration
    63  	// NumParallel - the number of parallel workers to spin up to record samples.  It is an error to specify both MinSamplingInterval and NumParallel.
    64  	NumParallel int
    65  }
    66  
    67  // The Units decorator allows you to specify units (an arbitrary string) when recording values.  It is ignored when recording durations.
    68  //
    69  //     e := gmeasure.NewExperiment("My Experiment")
    70  //     e.RecordValue("length", 3.141, gmeasure.Units("inches"))
    71  //
    72  // Units are only set the first time a value of a given name is recorded.  In the example above any subsequent calls to e.RecordValue("length", X) will maintain the "inches" units even if a new set of Units("UNIT") are passed in later.
    73  type Units string
    74  
    75  // The Annotation decorator allows you to attach an annotation to a given recorded data-point:
    76  //
    77  // For example:
    78  //
    79  //     e := gmeasure.NewExperiment("My Experiment")
    80  //     e.RecordValue("length", 3.141, gmeasure.Annotation("bob"))
    81  //     e.RecordValue("length", 2.71, gmeasure.Annotation("jane"))
    82  //
    83  // ...will result in a Measurement named "length" that records two values )[3.141, 2.71]) annotation with (["bob", "jane"])
    84  type Annotation string
    85  
    86  // The Style decorator allows you to associate a style with a measurement.  This is used to generate colorful console reports using Ginkgo V2's
    87  // console formatter.  Styles are strings in curly brackets that correspond to a color or style.
    88  //
    89  // For example:
    90  //
    91  //     e := gmeasure.NewExperiment("My Experiment")
    92  //     e.RecordValue("length", 3.141, gmeasure.Style("{{blue}}{{bold}}"))
    93  //     e.RecordValue("length", 2.71)
    94  //     e.RecordDuration("cooking time", 3 * time.Second, gmeasure.Style("{{red}}{{underline}}"))
    95  //     e.RecordDuration("cooking time", 2 * time.Second)
    96  //
    97  // will emit a report with blue bold entries for the length measurement and red underlined entries for the cooking time measurement.
    98  //
    99  // Units are only set the first time a value or duration of a given name is recorded.  In the example above any subsequent calls to e.RecordValue("length", X) will maintain the "{{blue}}{{bold}}" style even if a new Style is passed in later.
   100  type Style string
   101  
   102  // The PrecisionBundle decorator controls the rounding of value and duration measurements.  See Precision().
   103  type PrecisionBundle struct {
   104  	Duration    time.Duration
   105  	ValueFormat string
   106  }
   107  
   108  // Precision() allows you to specify the precision of a value or duration measurement - this precision is used when rendering the measurement to screen.
   109  //
   110  // To control the precision of Value measurements, pass Precision an integer.  This will denote the number of decimal places to render (equivalen to the format string "%.Nf")
   111  // To control the precision of Duration measurements, pass Precision a time.Duration.  Duration measurements will be rounded oo the nearest time.Duration when rendered.
   112  //
   113  // For example:
   114  //
   115  //     e := gmeasure.NewExperiment("My Experiment")
   116  //     e.RecordValue("length", 3.141, gmeasure.Precision(2))
   117  //     e.RecordValue("length", 2.71)
   118  //     e.RecordDuration("cooking time", 3214 * time.Millisecond, gmeasure.Precision(100*time.Millisecond))
   119  //     e.RecordDuration("cooking time", 2623 * time.Millisecond)
   120  func Precision(p interface{}) PrecisionBundle {
   121  	out := DefaultPrecisionBundle
   122  	switch reflect.TypeOf(p) {
   123  	case reflect.TypeOf(time.Duration(0)):
   124  		out.Duration = p.(time.Duration)
   125  	case reflect.TypeOf(int(0)):
   126  		out.ValueFormat = fmt.Sprintf("%%.%df", p.(int))
   127  	default:
   128  		panic("invalid precision type, must be time.Duration or int")
   129  	}
   130  	return out
   131  }
   132  
   133  // DefaultPrecisionBundle captures the default precisions for Vale and Duration measurements.
   134  var DefaultPrecisionBundle = PrecisionBundle{
   135  	Duration:    100 * time.Microsecond,
   136  	ValueFormat: "%.3f",
   137  }
   138  
   139  type extractedDecorations struct {
   140  	annotation      Annotation
   141  	units           Units
   142  	precisionBundle PrecisionBundle
   143  	style           Style
   144  }
   145  
   146  func extractDecorations(args []interface{}) extractedDecorations {
   147  	var out extractedDecorations
   148  	out.precisionBundle = DefaultPrecisionBundle
   149  
   150  	for _, arg := range args {
   151  		switch reflect.TypeOf(arg) {
   152  		case reflect.TypeOf(out.annotation):
   153  			out.annotation = arg.(Annotation)
   154  		case reflect.TypeOf(out.units):
   155  			out.units = arg.(Units)
   156  		case reflect.TypeOf(out.precisionBundle):
   157  			out.precisionBundle = arg.(PrecisionBundle)
   158  		case reflect.TypeOf(out.style):
   159  			out.style = arg.(Style)
   160  		default:
   161  			panic(fmt.Sprintf("unrecognized argument %#v", arg))
   162  		}
   163  	}
   164  
   165  	return out
   166  }
   167  
   168  /*
   169  Experiment is gmeasure's core data type.  You use experiments to record Measurements and generate reports.
   170  Experiments are thread-safe and all methods can be called from multiple goroutines.
   171  */
   172  type Experiment struct {
   173  	Name string
   174  
   175  	// Measurements includes all Measurements recorded by this experiment.  You should access them by name via Get() and GetStats()
   176  	Measurements Measurements
   177  	lock         *sync.Mutex
   178  }
   179  
   180  /*
   181  NexExperiment creates a new experiment with the passed-in name.
   182  
   183  When using Ginkgo we recommend immediately registering the experiment as a ReportEntry:
   184  
   185  	experiment = NewExperiment("My Experiment")
   186  	AddReportEntry(experiment.Name, experiment)
   187  
   188  this will ensure an experiment report is emitted as part of the test output and exported with any test reports.
   189  */
   190  func NewExperiment(name string) *Experiment {
   191  	experiment := &Experiment{
   192  		Name: name,
   193  		lock: &sync.Mutex{},
   194  	}
   195  	return experiment
   196  }
   197  
   198  func (e *Experiment) report(enableStyling bool) string {
   199  	t := table.NewTable()
   200  	t.TableStyle.EnableTextStyling = enableStyling
   201  	t.AppendRow(table.R(
   202  		table.C("Name"), table.C("N"), table.C("Min"), table.C("Median"), table.C("Mean"), table.C("StdDev"), table.C("Max"),
   203  		table.Divider("="),
   204  		"{{bold}}",
   205  	))
   206  
   207  	for _, measurement := range e.Measurements {
   208  		r := table.R(measurement.Style)
   209  		t.AppendRow(r)
   210  		switch measurement.Type {
   211  		case MeasurementTypeNote:
   212  			r.AppendCell(table.C(measurement.Note))
   213  		case MeasurementTypeValue, MeasurementTypeDuration:
   214  			name := measurement.Name
   215  			if measurement.Units != "" {
   216  				name += " [" + measurement.Units + "]"
   217  			}
   218  			r.AppendCell(table.C(name))
   219  			r.AppendCell(measurement.Stats().cells()...)
   220  		}
   221  	}
   222  
   223  	out := e.Name + "\n"
   224  	if enableStyling {
   225  		out = "{{bold}}" + out + "{{/}}"
   226  	}
   227  	out += t.Render()
   228  	return out
   229  }
   230  
   231  /*
   232  ColorableString returns a Ginkgo formatted summary of the experiment and all its Measurements.
   233  It is called automatically by Ginkgo's reporting infrastructure when the Experiment is registered as a ReportEntry via AddReportEntry.
   234  */
   235  func (e *Experiment) ColorableString() string {
   236  	return e.report(true)
   237  }
   238  
   239  /*
   240  ColorableString returns an unformatted summary of the experiment and all its Measurements.
   241  */
   242  func (e *Experiment) String() string {
   243  	return e.report(false)
   244  }
   245  
   246  /*
   247  RecordNote records a Measurement of type MeasurementTypeNote - this is simply a textual note to annotate the experiment.  It will be emitted in any experiment reports.
   248  
   249  RecordNote supports the Style() decoration.
   250  */
   251  func (e *Experiment) RecordNote(note string, args ...interface{}) {
   252  	decorations := extractDecorations(args)
   253  
   254  	e.lock.Lock()
   255  	defer e.lock.Unlock()
   256  	e.Measurements = append(e.Measurements, Measurement{
   257  		ExperimentName: e.Name,
   258  		Type:           MeasurementTypeNote,
   259  		Note:           note,
   260  		Style:          string(decorations.style),
   261  	})
   262  }
   263  
   264  /*
   265  RecordDuration records the passed-in duration on a Duration Measurement with the passed-in name.  If the Measurement does not exist it is created.
   266  
   267  RecordDuration supports the Style(), Precision(), and Annotation() decorations.
   268  */
   269  func (e *Experiment) RecordDuration(name string, duration time.Duration, args ...interface{}) {
   270  	decorations := extractDecorations(args)
   271  	e.recordDuration(name, duration, decorations)
   272  }
   273  
   274  /*
   275  MeasureDuration runs the passed-in callback and times how long it takes to complete.  The resulting duration is recorded on a Duration Measurement with the passed-in name.  If the Measurement does not exist it is created.
   276  
   277  MeasureDuration supports the Style(), Precision(), and Annotation() decorations.
   278  */
   279  func (e *Experiment) MeasureDuration(name string, callback func(), args ...interface{}) time.Duration {
   280  	t := time.Now()
   281  	callback()
   282  	duration := time.Since(t)
   283  	e.RecordDuration(name, duration, args...)
   284  	return duration
   285  }
   286  
   287  /*
   288  SampleDuration samples the passed-in callback and times how long it takes to complete each sample.
   289  The resulting durations are recorded on a Duration Measurement with the passed-in name.  If the Measurement does not exist it is created.
   290  
   291  The callback is given a zero-based index that increments by one between samples.  The Sampling is configured via the passed-in SamplingConfig
   292  
   293  SampleDuration supports the Style(), Precision(), and Annotation() decorations.  When passed an Annotation() the same annotation is applied to all sample measurements.
   294  */
   295  func (e *Experiment) SampleDuration(name string, callback func(idx int), samplingConfig SamplingConfig, args ...interface{}) {
   296  	decorations := extractDecorations(args)
   297  	e.Sample(func(idx int) {
   298  		t := time.Now()
   299  		callback(idx)
   300  		duration := time.Since(t)
   301  		e.recordDuration(name, duration, decorations)
   302  	}, samplingConfig)
   303  }
   304  
   305  /*
   306  SampleDuration samples the passed-in callback and times how long it takes to complete each sample.
   307  The resulting durations are recorded on a Duration Measurement with the passed-in name.  If the Measurement does not exist it is created.
   308  
   309  The callback is given a zero-based index that increments by one between samples.  The callback must return an Annotation - this annotation is attached to the measured duration.
   310  
   311  The Sampling is configured via the passed-in SamplingConfig
   312  
   313  SampleAnnotatedDuration supports the Style() and Precision() decorations.
   314  */
   315  func (e *Experiment) SampleAnnotatedDuration(name string, callback func(idx int) Annotation, samplingConfig SamplingConfig, args ...interface{}) {
   316  	decorations := extractDecorations(args)
   317  	e.Sample(func(idx int) {
   318  		t := time.Now()
   319  		decorations.annotation = callback(idx)
   320  		duration := time.Since(t)
   321  		e.recordDuration(name, duration, decorations)
   322  	}, samplingConfig)
   323  }
   324  
   325  func (e *Experiment) recordDuration(name string, duration time.Duration, decorations extractedDecorations) {
   326  	e.lock.Lock()
   327  	defer e.lock.Unlock()
   328  	idx := e.Measurements.IdxWithName(name)
   329  	if idx == -1 {
   330  		measurement := Measurement{
   331  			ExperimentName:  e.Name,
   332  			Type:            MeasurementTypeDuration,
   333  			Name:            name,
   334  			Units:           "duration",
   335  			Durations:       []time.Duration{duration},
   336  			PrecisionBundle: decorations.precisionBundle,
   337  			Style:           string(decorations.style),
   338  			Annotations:     []string{string(decorations.annotation)},
   339  		}
   340  		e.Measurements = append(e.Measurements, measurement)
   341  	} else {
   342  		if e.Measurements[idx].Type != MeasurementTypeDuration {
   343  			panic(fmt.Sprintf("attempting to record duration with name '%s'.  That name is already in-use for recording values.", name))
   344  		}
   345  		e.Measurements[idx].Durations = append(e.Measurements[idx].Durations, duration)
   346  		e.Measurements[idx].Annotations = append(e.Measurements[idx].Annotations, string(decorations.annotation))
   347  	}
   348  }
   349  
   350  /*
   351  NewStopwatch() returns a stopwatch configured to record duration measurements with this experiment.
   352  */
   353  func (e *Experiment) NewStopwatch() *Stopwatch {
   354  	return newStopwatch(e)
   355  }
   356  
   357  /*
   358  RecordValue records the passed-in value on a Value Measurement with the passed-in name.  If the Measurement does not exist it is created.
   359  
   360  RecordValue supports the Style(), Units(), Precision(), and Annotation() decorations.
   361  */
   362  func (e *Experiment) RecordValue(name string, value float64, args ...interface{}) {
   363  	decorations := extractDecorations(args)
   364  	e.recordValue(name, value, decorations)
   365  }
   366  
   367  /*
   368  MeasureValue runs the passed-in callback and records the return value on a Value Measurement with the passed-in name.  If the Measurement does not exist it is created.
   369  
   370  MeasureValue supports the Style(), Units(), Precision(), and Annotation() decorations.
   371  */
   372  func (e *Experiment) MeasureValue(name string, callback func() float64, args ...interface{}) float64 {
   373  	value := callback()
   374  	e.RecordValue(name, value, args...)
   375  	return value
   376  }
   377  
   378  /*
   379  SampleValue samples the passed-in callback and records the return value on a Value Measurement with the passed-in name. If the Measurement does not exist it is created.
   380  
   381  The callback is given a zero-based index that increments by one between samples.  The callback must return a float64.  The Sampling is configured via the passed-in SamplingConfig
   382  
   383  SampleValue supports the Style(), Units(), Precision(), and Annotation() decorations.  When passed an Annotation() the same annotation is applied to all sample measurements.
   384  */
   385  func (e *Experiment) SampleValue(name string, callback func(idx int) float64, samplingConfig SamplingConfig, args ...interface{}) {
   386  	decorations := extractDecorations(args)
   387  	e.Sample(func(idx int) {
   388  		value := callback(idx)
   389  		e.recordValue(name, value, decorations)
   390  	}, samplingConfig)
   391  }
   392  
   393  /*
   394  SampleAnnotatedValue samples the passed-in callback and records the return value on a Value Measurement with the passed-in name. If the Measurement does not exist it is created.
   395  
   396  The callback is given a zero-based index that increments by one between samples.  The callback must return a float64 and an Annotation - the annotation is attached to the recorded value.
   397  
   398  The Sampling is configured via the passed-in SamplingConfig
   399  
   400  SampleValue supports the Style(), Units(), and Precision() decorations.
   401  */
   402  func (e *Experiment) SampleAnnotatedValue(name string, callback func(idx int) (float64, Annotation), samplingConfig SamplingConfig, args ...interface{}) {
   403  	decorations := extractDecorations(args)
   404  	e.Sample(func(idx int) {
   405  		var value float64
   406  		value, decorations.annotation = callback(idx)
   407  		e.recordValue(name, value, decorations)
   408  	}, samplingConfig)
   409  }
   410  
   411  func (e *Experiment) recordValue(name string, value float64, decorations extractedDecorations) {
   412  	e.lock.Lock()
   413  	defer e.lock.Unlock()
   414  	idx := e.Measurements.IdxWithName(name)
   415  	if idx == -1 {
   416  		measurement := Measurement{
   417  			ExperimentName:  e.Name,
   418  			Type:            MeasurementTypeValue,
   419  			Name:            name,
   420  			Style:           string(decorations.style),
   421  			Units:           string(decorations.units),
   422  			PrecisionBundle: decorations.precisionBundle,
   423  			Values:          []float64{value},
   424  			Annotations:     []string{string(decorations.annotation)},
   425  		}
   426  		e.Measurements = append(e.Measurements, measurement)
   427  	} else {
   428  		if e.Measurements[idx].Type != MeasurementTypeValue {
   429  			panic(fmt.Sprintf("attempting to record value with name '%s'.  That name is already in-use for recording durations.", name))
   430  		}
   431  		e.Measurements[idx].Values = append(e.Measurements[idx].Values, value)
   432  		e.Measurements[idx].Annotations = append(e.Measurements[idx].Annotations, string(decorations.annotation))
   433  	}
   434  }
   435  
   436  /*
   437  Sample samples the passed-in callback repeatedly.  The sampling is governed by the passed in SamplingConfig.
   438  
   439  The SamplingConfig can limit the total number of samples and/or the total time spent sampling the callback.
   440  The SamplingConfig can also instruct Sample to run with multiple concurrent workers.
   441  
   442  The callback is called with a zero-based index that incerements by one between samples.
   443  */
   444  func (e *Experiment) Sample(callback func(idx int), samplingConfig SamplingConfig) {
   445  	if samplingConfig.N == 0 && samplingConfig.Duration == 0 {
   446  		panic("you must specify at least one of SamplingConfig.N and SamplingConfig.Duration")
   447  	}
   448  	if samplingConfig.MinSamplingInterval > 0 && samplingConfig.NumParallel > 1 {
   449  		panic("you cannot specify both SamplingConfig.MinSamplingInterval and SamplingConfig.NumParallel")
   450  	}
   451  	maxTime := time.Now().Add(100000 * time.Hour)
   452  	if samplingConfig.Duration > 0 {
   453  		maxTime = time.Now().Add(samplingConfig.Duration)
   454  	}
   455  	maxN := math.MaxInt32
   456  	if samplingConfig.N > 0 {
   457  		maxN = samplingConfig.N
   458  	}
   459  	numParallel := 1
   460  	if samplingConfig.NumParallel > numParallel {
   461  		numParallel = samplingConfig.NumParallel
   462  	}
   463  	minSamplingInterval := samplingConfig.MinSamplingInterval
   464  
   465  	work := make(chan int)
   466  	defer close(work)
   467  	if numParallel > 1 {
   468  		for worker := 0; worker < numParallel; worker++ {
   469  			go func() {
   470  				for idx := range work {
   471  					callback(idx)
   472  				}
   473  			}()
   474  		}
   475  	}
   476  
   477  	idx := 0
   478  	var avgDt time.Duration
   479  	for {
   480  		t := time.Now()
   481  		if numParallel > 1 {
   482  			work <- idx
   483  		} else {
   484  			callback(idx)
   485  		}
   486  		dt := time.Since(t)
   487  		if numParallel == 1 && dt < minSamplingInterval {
   488  			time.Sleep(minSamplingInterval - dt)
   489  			dt = time.Since(t)
   490  		}
   491  		if idx >= numParallel {
   492  			avgDt = (avgDt*time.Duration(idx-numParallel) + dt) / time.Duration(idx-numParallel+1)
   493  		}
   494  		idx += 1
   495  		if idx >= maxN {
   496  			return
   497  		}
   498  		if time.Now().Add(avgDt).After(maxTime) {
   499  			return
   500  		}
   501  	}
   502  }
   503  
   504  /*
   505  Get returns the Measurement with the associated name.  If no Measurement is found a zero Measurement{} is returned.
   506  */
   507  func (e *Experiment) Get(name string) Measurement {
   508  	e.lock.Lock()
   509  	defer e.lock.Unlock()
   510  	idx := e.Measurements.IdxWithName(name)
   511  	if idx == -1 {
   512  		return Measurement{}
   513  	}
   514  	return e.Measurements[idx]
   515  }
   516  
   517  /*
   518  GetStats returns the Stats for the Measurement with the associated name.  If no Measurement is found a zero Stats{} is returned.
   519  
   520  experiment.GetStats(name) is equivalent to experiment.Get(name).Stats()
   521  */
   522  func (e *Experiment) GetStats(name string) Stats {
   523  	measurement := e.Get(name)
   524  	e.lock.Lock()
   525  	defer e.lock.Unlock()
   526  	return measurement.Stats()
   527  }
   528  

View as plain text