diff --git a/tools/rw-heatmaps/cmd/root.go b/tools/rw-heatmaps/cmd/root.go index 49b98a9fca2..a0b6ab2f8cb 100644 --- a/tools/rw-heatmaps/cmd/root.go +++ b/tools/rw-heatmaps/cmd/root.go @@ -33,6 +33,8 @@ var ( ErrMissingInputFileArg = fmt.Errorf("missing input file argument") // ErrInvalidOutputFormat is returned when the output format is invalid. ErrInvalidOutputFormat = fmt.Errorf("invalid output format, must be one of png, jpg, jpeg, tiff") + // ErrInvalidChartTYpe is returned when the chart type is invalid. + ErrInvalidChartType = fmt.Errorf("invalid chart type, must be one of line, heatmap") ) // NewRootCommand returns the root command for the rw-heatmaps tool. @@ -56,6 +58,10 @@ func NewRootCommand() *cobra.Command { } } + if o.chartType == "line" { + return chart.PlotLineCharts(datasets, o.title, o.outputImageFile, o.outputFormat) + } + return chart.PlotHeatMaps(datasets, o.title, o.outputImageFile, o.outputFormat, o.zeroCentered) }, } @@ -70,6 +76,7 @@ type options struct { outputImageFile string outputFormat string zeroCentered bool + chartType string } // newOptions returns a new options for the command with the default values applied. @@ -77,6 +84,7 @@ func newOptions() options { return options{ outputFormat: "jpg", zeroCentered: true, + chartType: "heatmap", } } @@ -86,6 +94,7 @@ func (o *options) AddFlags(fs *pflag.FlagSet) { fs.StringVarP(&o.outputImageFile, "output-image-file", "o", o.outputImageFile, "output image filename (required)") fs.StringVarP(&o.outputFormat, "output-format", "f", o.outputFormat, "output image file format") fs.BoolVar(&o.zeroCentered, "zero-centered", o.zeroCentered, "plot the improvement graph with white color represents 0.0") + fs.StringVarP(&o.chartType, "chart-type", "c", o.chartType, "type of chart to plot (line, heatmap)") } // Validate returns an error if the options are invalid. @@ -101,5 +110,10 @@ func (o *options) Validate() error { default: return ErrInvalidOutputFormat } + switch o.chartType { + case "line", "heatmap": + default: + return ErrInvalidChartType + } return nil } diff --git a/tools/rw-heatmaps/pkg/chart/line_chart.go b/tools/rw-heatmaps/pkg/chart/line_chart.go new file mode 100644 index 00000000000..2415793e09c --- /dev/null +++ b/tools/rw-heatmaps/pkg/chart/line_chart.go @@ -0,0 +1,239 @@ +// Copyright 2024 The etcd Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package chart + +import ( + "cmp" + "fmt" + "image/color" + "slices" + "sort" + + "gonum.org/v1/plot" + "gonum.org/v1/plot/font" + "gonum.org/v1/plot/plotter" + "gonum.org/v1/plot/plotutil" + "gonum.org/v1/plot/vg" + "gonum.org/v1/plot/vg/draw" + "gonum.org/v1/plot/vg/vgimg" + + "go.etcd.io/etcd/tools/rw-heatmaps/v3/pkg/dataset" +) + +// PlotLineCharts creates a new line chart. +func PlotLineCharts(datasets []*dataset.DataSet, title, outputImageFile, outputFormat string) error { + plot.DefaultFont = font.Font{ + Typeface: "Liberation", + Variant: "Sans", + } + + var canvas *vgimg.Canvas + canvas = plotLineChart(datasets, title) + if err := saveCanvas(canvas, "readwrite", outputImageFile, outputFormat); err != nil { + return err + } + + return nil +} + +func plotLineChart(datasets []*dataset.DataSet, title string) *vgimg.Canvas { + ratiosLength := func() int { + max := slices.MaxFunc(datasets, func(a, b *dataset.DataSet) int { + return cmp.Compare(len(a.GetSortedRatios()), len(b.GetSortedRatios())) + }) + return len(max.GetSortedRatios()) + }() + + // Make a nx1 grid of heatmaps. + rows, cols := ratiosLength, 1 + + // Set the width and height of the canvas. + width, height := 30*vg.Centimeter, 15*font.Length(ratiosLength)*vg.Centimeter + + canvas := vgimg.New(width, height) + dc := draw.New(canvas) + + // Create a tiled layout for the plots. + t := draw.Tiles{ + Rows: rows, + Cols: cols, + PadX: vg.Millimeter * 4, + PadY: vg.Millimeter * 4, + PadTop: vg.Millimeter * 15, + PadBottom: vg.Millimeter * 2, + PadLeft: vg.Millimeter * 2, + PadRight: vg.Millimeter * 2, + } + + plots := make([][]*plot.Plot, rows) + legends := make([][]plot.Legend, rows) + for i := range plots { + plots[i] = make([]*plot.Plot, cols) + legends[i] = make([]plot.Legend, cols) + } + + // Load records into the grid. + ratios := slices.MaxFunc(datasets, func(a, b *dataset.DataSet) int { + return cmp.Compare(len(a.GetSortedRatios()), len(b.GetSortedRatios())) + }).GetSortedRatios() + + row, col := 0, 0 + for _, ratio := range ratios { + var records [][]dataset.DataRecord + var fileNames []string + for _, d := range datasets { + records = append(records, d.Records[ratio]) + fileNames = append(fileNames, d.FileName) + } + p, l := plotIndividualLineChart(fmt.Sprintf("R/W Ratio %0.04f", ratio), records, fileNames) + plots[row][col] = p + legends[row][col] = l + + if col++; col == cols { + col = 0 + row++ + } + } + + // Fill the canvas with the plots and legends. + canvases := plot.Align(plots, t, dc) + for i := 0; i < rows; i++ { + for j := 0; j < cols; j++ { + // Continue if there is no plot in the current cell (incomplete data). + if plots[i][j] == nil { + continue + } + + l := legends[i][j] + r := l.Rectangle(canvases[i][j]) + legendWidth := r.Max.X - r.Min.X + // Adjust the legend down a little. + l.YOffs = plots[i][j].Title.TextStyle.FontExtents().Height * 3 + l.Draw(canvases[i][j]) + + c := draw.Crop(canvases[i][j], 0, -legendWidth-vg.Millimeter, 0, 0) + plots[i][j].Draw(c) + } + } + + // Add the title and parameter legend. + l := plot.NewLegend() + l.Add(title) + for _, d := range datasets { + l.Add(fmt.Sprintf("%s: %s", d.FileName, d.Param)) + } + l.Top = true + l.Left = true + l.Draw(dc) + + return canvas +} + +func plotIndividualLineChart(title string, records [][]dataset.DataRecord, fileNames []string) (*plot.Plot, plot.Legend) { + p := plot.New() + p.Title.Text = title + p.X.Label.Text = "Connections Amount" + p.X.Scale = plot.LogScale{} + p.X.Tick.Marker = pow2Ticks{} + p.Y.Label.Text = "QPS (Requests/sec)" + p.Y.Scale = plot.LogScale{} + p.Y.Tick.Marker = pow2Ticks{} + + legend := plot.NewLegend() + + values := getSortedValueSizes(records...) + for i, rs := range records { + rec := make(map[int64][]dataset.DataRecord) + for _, r := range rs { + rec[r.ValueSize] = append(rec[r.ValueSize], r) + } + if len(records) > 1 { + addValues(p, &legend, values, rec, i, fileNames[i]) + } else { + addValues(p, &legend, values, rec, i, "") + } + } + + return p, legend +} + +func getSortedValueSizes(records ...[]dataset.DataRecord) []int { + valueMap := make(map[int64]struct{}) + for _, rs := range records { + for _, r := range rs { + valueMap[r.ValueSize] = struct{}{} + } + } + + var values []int + for v := range valueMap { + values = append(values, int(v)) + } + sort.Ints(values) + + return values +} + +func addValues(p *plot.Plot, legend *plot.Legend, values []int, rec map[int64][]dataset.DataRecord, index int, fileName string) { + for i, value := range values { + r := rec[int64(value)] + readPts := make(plotter.XYs, len(r)) + writePts := make(plotter.XYs, len(r)) + for i, record := range r { + writePts[i].X = float64(record.ConnSize) + readPts[i].X = writePts[i].X + readPts[i].Y = record.AvgRead + writePts[i].Y = record.AvgWrite + } + + readLine, s, err := plotter.NewLinePoints(readPts) + if err != nil { + panic(err) + } + if index == 0 { + readLine.Color = plotutil.Color(0) + } else { + readLine.Color = plotutil.Color(2) + } + readLine.Width = vg.Length(vg.Millimeter * 0.15 * vg.Length(i+1)) + readLine.Dashes = []vg.Length{vg.Points(6), vg.Points(2)} + s.Color = readLine.Color + p.Add(readLine, s) + + writeLine, s, err := plotter.NewLinePoints(writePts) + if err != nil { + panic(err) + } + if index == 0 { + writeLine.Color = plotutil.Color(0) + } else { + writeLine.Color = plotutil.Color(2) + } + writeLine.Width = vg.Length(vg.Millimeter * 0.15 * vg.Length(i+1)) + s.Color = writeLine.Color + p.Add(writeLine, s) + + if index == 0 { + l, _, _ := plotter.NewLinePoints(writePts) + l.Color = color.RGBA{0, 0, 0, 255} + l.Width = vg.Length(vg.Millimeter * 0.15 * vg.Length(i+1)) + legend.Add(fmt.Sprintf("%d", value), plot.Thumbnailer(l)) + } + if i == len(values)-1 { + legend.Add(fmt.Sprintf("read %s", fileName), plot.Thumbnailer(readLine)) + legend.Add(fmt.Sprintf("write %s", fileName), plot.Thumbnailer(writeLine)) + } + } +}