From b8c4391f8dbfbf3bd6c78d912899b183e525158e Mon Sep 17 00:00:00 2001 From: Ivan Valdes Date: Wed, 31 Jan 2024 21:41:42 -0800 Subject: [PATCH] tools/rw-heatmaps: reimplement in golang Signed-off-by: Ivan Valdes --- tools/rw-heatmaps/README.md | 30 +- tools/rw-heatmaps/cmd/root.go | 105 ++++++ tools/rw-heatmaps/go.mod | 23 ++ tools/rw-heatmaps/go.sum | 92 +++++ tools/rw-heatmaps/main.go | 379 +------------------- tools/rw-heatmaps/pkg/chart/heatmap_grid.go | 130 +++++++ tools/rw-heatmaps/pkg/chart/heatmaps.go | 346 ++++++++++++++++++ tools/rw-heatmaps/pkg/dataset/dataset.go | 122 +++++++ tools/rw-heatmaps/plot_data.py | 181 ---------- tools/rw-heatmaps/requirements.txt | 3 - 10 files changed, 861 insertions(+), 550 deletions(-) create mode 100644 tools/rw-heatmaps/cmd/root.go create mode 100644 tools/rw-heatmaps/go.mod create mode 100644 tools/rw-heatmaps/go.sum create mode 100644 tools/rw-heatmaps/pkg/chart/heatmap_grid.go create mode 100644 tools/rw-heatmaps/pkg/chart/heatmaps.go create mode 100644 tools/rw-heatmaps/pkg/dataset/dataset.go delete mode 100755 tools/rw-heatmaps/plot_data.py delete mode 100644 tools/rw-heatmaps/requirements.txt diff --git a/tools/rw-heatmaps/README.md b/tools/rw-heatmaps/README.md index 893ea9871cd3..091ed3dccf11 100644 --- a/tools/rw-heatmaps/README.md +++ b/tools/rw-heatmaps/README.md @@ -12,15 +12,35 @@ To get a mixed read/write performance evaluation result: ``` `rw-benchmark.sh` will automatically use the etcd binary compiled under `etcd/bin/` directory. -Note: the result csv file will be saved to current working directory. The working directory is where etcd database is saved. The working directory is designed for scenarios where a different mounted disk is preferred. +Note: the result CSV file will be saved to current working directory. The working directory is where etcd database is saved. The working directory is designed for scenarios where a different mounted disk is preferred. ### Plot Graphs -To generate two images (read and write) based on the benchmark result csv file: +To generate two images (read and write) based on the benchmark result CSV file: ```sh # to generate a pair of read & write images from one data csv file -./plot_data.py ${FIRST_CSV_FILE} -t ${IMAGE_TITLE} -o ${OUTPUT_IMAGE_NAME} +go run . ${CSV_FILE} -t ${IMAGE_TITLE} -o ${OUTPUT_IMAGE_NAME} +``` + +To generate two images (read and write) showing the performance difference from two result CSV files: +```sh +# to generate a pair of read & write images from one data csv file +go run . ${CSV_FILE1} ${CSV_FILE2} -t ${IMAGE_TITLE} -o ${OUTPUT_IMAGE_NAME} +``` + +To see the available options use the `-h` option. + +```sh +$ go run . -h + +rw-heatmaps is a tool to generate read/write heatmaps images for etcd3. +Usage: + rw-heatmaps [input file(s) in csv format] [flags] -# to generate a pair of read & write images by comparing two data csv files -./plot_data.py ${FIRST_CSV_FILE} ${SECOND_CSV_FILE} -t ${IMAGE_TITLE} -o ${OUTPUT_IMAGE_NAME} +Flags: + -h, --help help for rw-heatmaps + -f, --output-format string output image file format (default "jpg") + -o, --output-image-file string output image filename (required) + -t, --title string plot graph title (required) + --zero-centered plot the improvement graph with white color represents 0.0 (default true) ``` diff --git a/tools/rw-heatmaps/cmd/root.go b/tools/rw-heatmaps/cmd/root.go new file mode 100644 index 000000000000..49b98a9fca29 --- /dev/null +++ b/tools/rw-heatmaps/cmd/root.go @@ -0,0 +1,105 @@ +// 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 cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "go.etcd.io/etcd/tools/rw-heatmaps/v3/pkg/chart" + "go.etcd.io/etcd/tools/rw-heatmaps/v3/pkg/dataset" +) + +var ( + // ErrMissingTitleArg is returned when the title argument is missing. + ErrMissingTitleArg = fmt.Errorf("missing title argument") + // ErrMissingOutputImageFileArg is returned when the output image file argument is missing. + ErrMissingOutputImageFileArg = fmt.Errorf("missing output image file argument") + // ErrMissingInputFileArg is returned when the input file argument is missing. + 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") +) + +// NewRootCommand returns the root command for the rw-heatmaps tool. +func NewRootCommand() *cobra.Command { + o := newOptions() + rootCmd := &cobra.Command{ + Use: "rw-heatmaps [input file(s) in csv format]", + Short: "A tool to generate read/write heatmaps for etcd3", + Long: "rw-heatmaps is a tool to generate read/write heatmaps images for etcd3.", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + if err := o.Validate(); err != nil { + return err + } + + datasets := make([]*dataset.DataSet, len(args)) + for i, arg := range args { + var err error + if datasets[i], err = dataset.LoadCSVData(arg); err != nil { + return err + } + } + + return chart.PlotHeatMaps(datasets, o.title, o.outputImageFile, o.outputFormat, o.zeroCentered) + }, + } + + o.AddFlags(rootCmd.Flags()) + return rootCmd +} + +// options holds the options for the command. +type options struct { + title string + outputImageFile string + outputFormat string + zeroCentered bool +} + +// newOptions returns a new options for the command with the default values applied. +func newOptions() options { + return options{ + outputFormat: "jpg", + zeroCentered: true, + } +} + +// AddFlags sets the flags for the command. +func (o *options) AddFlags(fs *pflag.FlagSet) { + fs.StringVarP(&o.title, "title", "t", o.title, "plot graph title (required)") + 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") +} + +// Validate returns an error if the options are invalid. +func (o *options) Validate() error { + if o.title == "" { + return ErrMissingTitleArg + } + if o.outputImageFile == "" { + return ErrMissingOutputImageFileArg + } + switch o.outputFormat { + case "png", "jpg", "jpeg", "tiff": + default: + return ErrInvalidOutputFormat + } + return nil +} diff --git a/tools/rw-heatmaps/go.mod b/tools/rw-heatmaps/go.mod new file mode 100644 index 000000000000..1fad2f55cd60 --- /dev/null +++ b/tools/rw-heatmaps/go.mod @@ -0,0 +1,23 @@ +module go.etcd.io/etcd/tools/rw-heatmaps/v3 + +go 1.21 + +require ( + github.com/spf13/cobra v1.8.0 + github.com/spf13/pflag v1.0.5 + gonum.org/v1/plot v0.14.0 +) + +require ( + git.sr.ht/~sbinet/gg v0.5.0 // indirect + github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b // indirect + github.com/campoy/embedmd v1.0.0 // indirect + github.com/go-fonts/liberation v0.3.1 // indirect + github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9 // indirect + github.com/go-pdf/fpdf v0.8.0 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/image v0.11.0 // indirect + golang.org/x/text v0.12.0 // indirect +) diff --git a/tools/rw-heatmaps/go.sum b/tools/rw-heatmaps/go.sum new file mode 100644 index 000000000000..6be926682145 --- /dev/null +++ b/tools/rw-heatmaps/go.sum @@ -0,0 +1,92 @@ +git.sr.ht/~sbinet/cmpimg v0.1.0 h1:E0zPRk2muWuCqSKSVZIWsgtU9pjsw3eKHi8VmQeScxo= +git.sr.ht/~sbinet/cmpimg v0.1.0/go.mod h1:FU12psLbF4TfNXkKH2ZZQ29crIqoiqTZmeQ7dkp/pxE= +git.sr.ht/~sbinet/gg v0.5.0 h1:6V43j30HM623V329xA9Ntq+WJrMjDxRjuAB1LFWF5m8= +git.sr.ht/~sbinet/gg v0.5.0/go.mod h1:G2C0eRESqlKhS7ErsNey6HHrqU1PwsnCQlekFi9Q2Oo= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= +github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= +github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= +github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= +github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY= +github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/go-fonts/dejavu v0.1.0 h1:JSajPXURYqpr+Cu8U9bt8K+XcACIHWqWrvWCKyeFmVQ= +github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= +github.com/go-fonts/latin-modern v0.3.1 h1:/cT8A7uavYKvglYXvrdDw4oS5ZLkcOU22fa2HJ1/JVM= +github.com/go-fonts/latin-modern v0.3.1/go.mod h1:ysEQXnuT/sCDOAONxC7ImeEDVINbltClhasMAqEtRK0= +github.com/go-fonts/liberation v0.3.1 h1:9RPT2NhUpxQ7ukUvz3jeUckmN42T9D9TpjtQcqK/ceM= +github.com/go-fonts/liberation v0.3.1/go.mod h1:jdJ+cqF+F4SUL2V+qxBth8fvBpBDS7yloUL5Fi8GTGY= +github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9 h1:NxXI5pTAtpEaU49bpLpQoDsu1zrteW/vxzTz8Cd2UAs= +github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9/go.mod h1:gWuR/CrFDDeVRFQwHPvsv9soJVB/iqymhuZQuJ3a9OM= +github.com/go-pdf/fpdf v0.8.0 h1:IJKpdaagnWUeSkUFUjTcSzTppFxmv8ucGQyNPQWxYOQ= +github.com/go-pdf/fpdf v0.8.0/go.mod h1:gfqhcNwXrsd3XYKte9a7vM3smvU/jB4ZRDrmWSxpfdc= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b h1:r+vk0EmXNmekl0S0BascoeeoHk/L7wmaW2QF90K+kYI= +golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/image v0.11.0 h1:ds2RoQvBvYTiJkwpSFDwCcDFNX7DqjL2WsUgTNk0Ooo= +golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.14.0 h1:2NiG67LD1tEH0D7kM+ps2V+fXmsAnpUeec7n8tcr4S0= +gonum.org/v1/gonum v0.14.0/go.mod h1:AoWeoz0becf9QMWtE8iWXNXc27fK4fNeHNf/oMejGfU= +gonum.org/v1/plot v0.14.0 h1:+LBDVFYwFe4LHhdP8coW6296MBEY4nQ+Y4vuUpJopcE= +gonum.org/v1/plot v0.14.0/go.mod h1:MLdR9424SJed+5VqC6MsouEpig9pZX2VZ57H9ko2bXU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= +rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/tools/rw-heatmaps/main.go b/tools/rw-heatmaps/main.go index ebe751ee1c82..cfd956a9ad0d 100644 --- a/tools/rw-heatmaps/main.go +++ b/tools/rw-heatmaps/main.go @@ -1,372 +1,29 @@ +// 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 main import ( - "encoding/csv" - "flag" "fmt" - "image/color" - "io" - "log" - "math" "os" - "sort" - "strconv" - "strings" - - "gonum.org/v1/plot" - "gonum.org/v1/plot/palette" - "gonum.org/v1/plot/palette/brewer" - "gonum.org/v1/plot/plotter" - "gonum.org/v1/plot/vg" - "gonum.org/v1/plot/vg/draw" - "gonum.org/v1/plot/vg/vgimg" -) -var ( - inputFileA string - inputFileB string - title string - zeroCentered bool - outputImage string - outputFormat string + "go.etcd.io/etcd/tools/rw-heatmaps/v3/cmd" ) -func init() { - log.SetFlags(0) - log.SetPrefix("[INFO] ") - - flag.StringVar(&inputFileA, "input_file_a", "", "first input data file in csv format. (required)") - flag.StringVar(&inputFileB, "input_file_b", "", "second input data file in csv format. (optional)") - flag.StringVar(&title, "title", "", "plot graph title string (required)") - flag.BoolVar(&zeroCentered, "zero-centered", true, "plot the improvement graph with white color represents 0.0") - flag.StringVar(&outputImage, "output-image-file", "", "output image filename (required)") - flag.StringVar(&outputFormat, "output-format", "png", "output image file format. default: png") - - flag.Parse() - - if inputFileA == "" || title == "" || outputImage == "" { - fmt.Println("Missing required arguments.") - flag.Usage() - os.Exit(2) - } -} - -type DataSet struct { - Records map[float64][]DataRecord - Param string -} - -type DataRecord struct { - ConnSize int64 - ValueSize int64 - Read float64 - Write float64 -} - -func loadCSVData(inputFile string) (*DataSet, error) { - file, err := os.Open(inputFile) - if err != nil { - return nil, err - } - defer file.Close() - - reader := csv.NewReader(file) - lines, err := reader.ReadAll() - if err != nil { - return nil, err - } - dataset := &DataSet{Records: make(map[float64][]DataRecord)} - records := dataset.Records - - iters := 0 - for _, header := range lines[0][4:] { - if strings.HasPrefix(header, "iter") { - iters++ - } - } - - for _, line := range lines[2:] { // Skip header line. - ratio, _ := strconv.ParseFloat(line[1], 64) - if _, ok := records[ratio]; !ok { - records[ratio] = make([]DataRecord, 0) - } - connSize, _ := strconv.ParseInt(line[2], 10, 64) - valueSize, _ := strconv.ParseInt(line[3], 10, 64) - - readSum := float64(0) - writeSum := float64(0) - - for _, v := range line[4 : 4+iters] { - splitted := strings.Split(v, ":") - readValue, _ := strconv.ParseFloat(splitted[0], 64) - readSum += readValue - - writeValue, _ := strconv.ParseFloat(splitted[1], 64) - writeSum += writeValue - } - - records[ratio] = append(records[ratio], DataRecord{ - ConnSize: connSize, - ValueSize: valueSize, - Read: readSum / float64(iters), - Write: writeSum / float64(iters), - }) - } - dataset.Param = lines[1][iters+4] - return dataset, nil -} - -// HeatMapGrid holds X, Y, Z values for a heatmap. -type HeatMapGrid struct { - x, y []float64 - z [][]float64 // The Z values should be arranged in a 2D slice. -} - -// Len implements the plotter.GridXYZ interface. -func (h *HeatMapGrid) Dims() (int, int) { - return len(h.x), len(h.y) -} - -// ValueAt returns the value of a grid cell at (c, r). -// It implements the plotter.GridXYZ interface. -func (h *HeatMapGrid) Z(c, r int) float64 { - return h.z[r][c] -} - -// X returns the coordinate for the column at index c. -// It implements the plotter.GridXYZ interface. -func (h *HeatMapGrid) X(c int) float64 { - if c >= len(h.x) { - panic("index out of range") - } - return h.x[c] -} - -// Y returns the coordinate for the row at index r. -// It implements the plotter.GridXYZ interface. -func (h *HeatMapGrid) Y(r int) float64 { - if r >= len(h.y) { - panic("index out of range") - } - return h.y[r] -} - -func uniqueSortedFloats(input []float64) []float64 { - unique := make([]float64, 0) - seen := make(map[float64]bool) - - for _, value := range input { - if !seen[value] { - seen[value] = true - unique = append(unique, value) - } - } - - sort.Float64s(unique) - return unique -} - -func populateGridAxes(records []DataRecord) ([]float64, []float64) { - var xslice, yslice []float64 - - for _, record := range records { - xslice = append(xslice, float64(record.ConnSize)) - yslice = append(yslice, float64(record.ValueSize)) - } - - // Sort and deduplicate the slices - xUnique := uniqueSortedFloats(xslice) - yUnique := uniqueSortedFloats(yslice) - - return xUnique, yUnique -} - -func plotHeatMaps(title, plotType string, dataset *DataSet) { - const rows, cols = 4, 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) - } - - row, col := 0, 0 - ratios := make([]float64, 0) - for ratio := range dataset.Records { - ratios = append(ratios, ratio) - } - sort.Float64s(ratios) - for _, ratio := range ratios { - records := dataset.Records[ratio] - p, l := plotHeatMap(fmt.Sprintf("R/W Ratio %0.04f", ratio), plotType, records) - plots[row][col] = p - legends[row][col] = l - - if col++; col == cols { - col = 0 - row++ - } - } - - const width, height = 12 * vg.Inch, 16 * vg.Inch - img := vgimg.New(width, height) - dc := draw.New(img) - - t := draw.Tiles{ - Rows: rows, - Cols: cols, - PadX: vg.Inch * 0.5, - PadY: vg.Inch * 0.5, - PadTop: vg.Inch * 0.5, - PadBottom: vg.Inch * 0.5, - PadLeft: vg.Inch * 0.25, - PadRight: vg.Inch * 0.25, - } - - canvases := plot.Align(plots, t, dc) - for i := 0; i < rows; i++ { - for j := 0; j < cols; j++ { - if plots[i][j] != nil { - l := legends[i][j] - r := l.Rectangle(canvases[i][j]) - legendWidth := r.Max.X - r.Min.X - l.YOffs = -plots[i][j].Title.TextStyle.FontExtents().Height // Adjust the legend down a little. - l.Draw(canvases[i][j]) - - c := draw.Crop(canvases[i][j], 0, -legendWidth-vg.Millimeter, 0, 0) // Make space for the legend. - plots[i][j].Draw(c) - } - } - } - - l := plot.NewLegend() - l.Add(title) - l.Add(dataset.Param) - l.Top = true - l.Left = true - l.Draw(dc) - - fh, err := os.Create(fmt.Sprintf("%s_%s_heatmap.%s", outputImage, plotType, outputFormat)) - if err != nil { - panic(err) - } - defer fh.Close() - - var w io.WriterTo - switch outputFormat { - case "png": - w = vgimg.PngCanvas{Canvas: img} - case "jpeg", "jpg": - w = vgimg.PngCanvas{Canvas: img} - case "tiff": - w = vgimg.TiffCanvas{Canvas: img} - } - if _, err := w.WriteTo(fh); err != nil { - panic(err) - } -} - -type pow2Ticks struct{} - -func (pow2Ticks) Ticks(min, max float64) []plot.Tick { - var t []plot.Tick - for i := math.Log2(min); math.Pow(2, i) <= max; i++ { - t = append(t, plot.Tick{ - Value: math.Pow(2, i), - Label: fmt.Sprintf("2^%d", int(i)), - }) - } - return t -} - -func plotHeatMap(title, plotType string, records []DataRecord) (*plot.Plot, plot.Legend) { - p := plot.New() - p.X.Scale = plot.LogScale{} - p.X.Tick.Marker = pow2Ticks{} - p.X.Label.Text = "Connections Amount" - p.Y.Scale = plot.LogScale{} - p.Y.Tick.Marker = pow2Ticks{} - p.Y.Label.Text = "Value Size" - - // Populate X and Y axis data from records - xCoords, yCoords := populateGridAxes(records) - - gridData := &HeatMapGrid{ - x: xCoords, - y: yCoords, - z: make([][]float64, len(yCoords)), - } - - for i := range gridData.z { - gridData.z[i] = make([]float64, len(xCoords)) - - for j := range gridData.z[i] { - recordIndex := i*len(gridData.x) + j - if recordIndex >= len(records) { - break - } - record := records[recordIndex] - if plotType == "read" { - gridData.z[i][j] = record.Read - } else { - gridData.z[i][j] = record.Write - } - } - } - - colors, _ := brewer.GetPalette(brewer.TypeAny, "YlGnBu", 9) - pal := invertedPalette{colors} - h := plotter.NewHeatMap(gridData, pal) - - p.Title.Text = title + fmt.Sprintf(" [%.2f, %.2f]", h.Min, h.Max) - p.Add(h) - - // Create a legend with the scale. - l := plot.NewLegend() - thumbs := plotter.PaletteThumbnailers(pal) - step := (h.Max - h.Min) / float64(len(thumbs)-1) - for i := len(thumbs) - 1; i >= 0; i-- { - t := thumbs[i] - l.Add(fmt.Sprintf("%.0f", h.Min+step*float64(i)), t) - } - l.Top = true - - return p, l -} - -// invertedPalette takes an existing palette and inverts it. -type invertedPalette struct { - Base palette.Palette -} - -// Colors returns the sequence of colors in reverse order from the base palette. -func (p invertedPalette) Colors() []color.Color { - baseColors := p.Base.Colors() - invertedColors := make([]color.Color, len(baseColors)) - for i, c := range baseColors { - invertedColors[len(baseColors)-i-1] = c - } - return invertedColors -} - func main() { - //var aRecords []DataRecord - //var bRecords []DataRecord - //var err error - - aRecords, err := loadCSVData(inputFileA) - if err != nil { - log.Fatalf("failed to load data from %s: %v\n", inputFileA, err) + if err := cmd.NewRootCommand().Execute(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err.Error()) + os.Exit(1) } - - // if inputFileB != "" { - // bRecords, err = loadCSVData(inputFileB) - // if err != nil { - // log.Fatalf("failed to load data from %s: %v\n", inputFileB, err) - // } - // } - - //plotHeatMap(title+" Read Plot", "read", aRecords, maxRead) - plotHeatMaps(fmt.Sprintf("%s [READ]", title), "read", aRecords) - plotHeatMaps(fmt.Sprintf("%s [WRITE]", title), "write", aRecords) } diff --git a/tools/rw-heatmaps/pkg/chart/heatmap_grid.go b/tools/rw-heatmaps/pkg/chart/heatmap_grid.go new file mode 100644 index 000000000000..c1d33f4676d6 --- /dev/null +++ b/tools/rw-heatmaps/pkg/chart/heatmap_grid.go @@ -0,0 +1,130 @@ +// 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 ( + "sort" + + "go.etcd.io/etcd/tools/rw-heatmaps/v3/pkg/dataset" +) + +// heatMapGrid holds X, Y, Z values for a heatmap. +type heatMapGrid struct { + x, y []float64 + z [][]float64 // The Z values should be arranged in a 2D slice. +} + +// newHeatMapGrid returns a new heatMapGrid. +func newHeatMapGrid(plotType string, records []dataset.DataRecord) *heatMapGrid { + x, y := populateGridAxes(records) + + // Create a 2D slice to hold the Z values. + z := make([][]float64, len(y)) + for i := range z { + z[i] = make([]float64, len(x)) + for j := range z[i] { + recordIndex := i*len(x) + j + // If the recordIndex is out of range (incomplete data), break the loop. + if recordIndex >= len(records) { + break + } + record := records[recordIndex] + if plotType == "read" { + z[i][j] = record.Read + } else { + z[i][j] = record.Write + } + } + } + + return &heatMapGrid{x, y, z} +} + +// newDeltaHeatMapGrid returns a new heatMapGrid for the delta heatmap. +func newDeltaHeatMapGrid(plotType string, records [][]dataset.DataRecord) *heatMapGrid { + delta := make([]dataset.DataRecord, len(records[0])) + for i := range records[0] { + delta[i] = dataset.DataRecord{ + ConnSize: records[0][i].ConnSize, + ValueSize: records[0][i].ValueSize, + Read: ((records[1][i].Read - records[0][i].Read) / records[0][i].Read) * 100, + Write: ((records[1][i].Write - records[0][i].Write) / records[0][i].Write) * 100, + } + } + + return newHeatMapGrid(plotType, delta) +} + +// Dims returns the number of elements in the grid. +// It implements the plotter.GridXYZ interface. +func (h *heatMapGrid) Dims() (int, int) { + return len(h.x), len(h.y) +} + +// Z returns the value of a grid cell at (c, r). +// It implements the plotter.GridXYZ interface. +func (h *heatMapGrid) Z(c, r int) float64 { + return h.z[r][c] +} + +// X returns the coordinate for the column at index c. +// It implements the plotter.GridXYZ interface. +func (h *heatMapGrid) X(c int) float64 { + if c >= len(h.x) { + panic("index out of range") + } + return h.x[c] +} + +// Y returns the coordinate for the row at index r. +// It implements the plotter.GridXYZ interface. +func (h *heatMapGrid) Y(r int) float64 { + if r >= len(h.y) { + panic("index out of range") + } + return h.y[r] +} + +// populateGridAxes populates the X and Y axes for the heatmap grid. +func populateGridAxes(records []dataset.DataRecord) ([]float64, []float64) { + var xslice, yslice []float64 + + for _, record := range records { + xslice = append(xslice, float64(record.ConnSize)) + yslice = append(yslice, float64(record.ValueSize)) + } + + // Sort and deduplicate the slices + xUnique := uniqueSortedFloats(xslice) + yUnique := uniqueSortedFloats(yslice) + + return xUnique, yUnique +} + +// uniqueSortedFloats returns a sorted slice of unique float64 values. +func uniqueSortedFloats(input []float64) []float64 { + unique := make([]float64, 0) + seen := make(map[float64]bool) + + for _, value := range input { + if !seen[value] { + seen[value] = true + unique = append(unique, value) + } + } + + sort.Float64s(unique) + return unique +} diff --git a/tools/rw-heatmaps/pkg/chart/heatmaps.go b/tools/rw-heatmaps/pkg/chart/heatmaps.go new file mode 100644 index 000000000000..3feb118737bd --- /dev/null +++ b/tools/rw-heatmaps/pkg/chart/heatmaps.go @@ -0,0 +1,346 @@ +// 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 ( + "fmt" + "image/color" + "io" + "math" + "os" + "strings" + + "gonum.org/v1/plot" + "gonum.org/v1/plot/font" + "gonum.org/v1/plot/palette" + "gonum.org/v1/plot/palette/brewer" + "gonum.org/v1/plot/plotter" + "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" +) + +// pow2Ticks is a type that implements the plot.Ticker interface for log2 scale. +type pow2Ticks struct{} + +// Ticks returns the ticks for the log2 scale. +// It implements the plot.Ticker interface. +func (pow2Ticks) Ticks(min, max float64) []plot.Tick { + var t []plot.Tick + for i := math.Log2(min); math.Pow(2, i) <= max; i++ { + t = append(t, plot.Tick{ + Value: math.Pow(2, i), + Label: fmt.Sprintf("2^%d", int(i)), + }) + } + return t +} + +// invertedPalette takes an existing palette and inverts it. +type invertedPalette struct { + base palette.Palette +} + +// Colors returns the sequence of colors in reverse order from the base palette. +// It implements the palette.Palette interface. +func (p invertedPalette) Colors() []color.Color { + baseColors := p.base.Colors() + invertedColors := make([]color.Color, len(baseColors)) + for i, c := range baseColors { + invertedColors[len(baseColors)-i-1] = c + } + return invertedColors +} + +// PlotHeatMaps plots, and saves the heatmaps for the given dataset. +func PlotHeatMaps(datasets []*dataset.DataSet, title, outputImageFile, outputFormat string, zeroCentered bool) error { + plot.DefaultFont = font.Font{ + Typeface: "Liberation", + Variant: "Sans", + } + + for _, plotType := range []string{"read", "write"} { + var canvas *vgimg.Canvas + if len(datasets) == 1 { + canvas = plotHeatMapGrid(datasets[0], title, plotType) + } else { + canvas = plotComparisonHeatMapGrid(datasets, title, plotType, zeroCentered) + } + if err := saveCanvas(canvas, plotType, outputImageFile, outputFormat); err != nil { + return err + } + } + return nil +} + +// plotHeatMapGrid plots a grid of heatmaps for the given dataset. +func plotHeatMapGrid(dataset *dataset.DataSet, title, plotType string) *vgimg.Canvas { + // Make a 4x2 grid of heatmaps. + const rows, cols = 4, 2 + + // Set the width and height of the canvas. + const width, height = 30 * vg.Centimeter, 40 * 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 * 10, + PadBottom: vg.Millimeter * 2, + PadLeft: vg.Millimeter * 2, + PadRight: vg.Millimeter * 2, + } + + // Store the plots and legends (scale label) in a grid. + 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 := dataset.GetSortedRatios() + row, col := 0, 0 + for _, ratio := range ratios { + records := dataset.Records[ratio] + p, l := plotIndividualHeatMap(fmt.Sprintf("R/W Ratio %0.04f", ratio), plotType, records) + 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 + l.Draw(canvases[i][j]) + + // Crop the plot to make space for the legend. + 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(fmt.Sprintf("%s [%s]", title, strings.ToUpper(plotType))) + l.Add(dataset.Param) + l.Top = true + l.Left = true + l.Draw(dc) + + return canvas +} + +// plotComparisonHeatMapGrid plots a grid of heatmaps for the given datasets. +func plotComparisonHeatMapGrid(datasets []*dataset.DataSet, title, plotType string, zeroCentered bool) *vgimg.Canvas { + // Make a 8x3 grid of heatmaps. + const rows, cols = 8, 3 + // Set the width and height of the canvas. + const width, height = 40 * vg.Centimeter, 66 * 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, + } + + // Store the plots and legends (scale label) in a grid. + 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 := datasets[0].GetSortedRatios() + for row, ratio := range ratios { + records := make([][]dataset.DataRecord, len(datasets)) + for col, dataset := range datasets { + r := dataset.Records[ratio] + p, l := plotIndividualHeatMap(fmt.Sprintf("R/W Ratio %0.04f", ratio), plotType, r) + // Add the title to the first row. + if row == 0 { + p.Title.Text = fmt.Sprintf("%s\n%s", dataset.FileName, p.Title.Text) + } + + plots[row][col] = p + legends[row][col] = l + records[col] = r + } + plots[row][2], legends[row][2] = plotDeltaHeatMap(fmt.Sprintf("R/W Ratio %0.04f", ratio), plotType, records, zeroCentered) + } + + // 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 + l.Draw(canvases[i][j]) + + // Crop the plot to make space for the legend. + 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(fmt.Sprintf("%s [%s]", title, strings.ToUpper(plotType))) + for _, dataset := range datasets { + l.Add(fmt.Sprintf("%s: %s", dataset.FileName, dataset.Param)) + } + l.Top = true + l.Left = true + l.Draw(dc) + + return canvas +} + +// saveCanvas saves the canvas to a file. +func saveCanvas(canvas *vgimg.Canvas, plotType, outputImageFile, outputFormat string) error { + f, err := os.Create(fmt.Sprintf("%s_%s.%s", outputImageFile, plotType, outputFormat)) + if err != nil { + return err + } + defer f.Close() + + var w io.WriterTo + switch outputFormat { + case "png": + w = vgimg.PngCanvas{Canvas: canvas} + case "jpeg", "jpg": + w = vgimg.PngCanvas{Canvas: canvas} + case "tiff": + w = vgimg.TiffCanvas{Canvas: canvas} + } + + _, err = w.WriteTo(f) + return err +} + +// plotIndividualHeatMap plots a heatmap for a given set of records. +func plotIndividualHeatMap(title, plotType string, records []dataset.DataRecord) (*plot.Plot, plot.Legend) { + p := plot.New() + p.X.Scale = plot.LogScale{} + p.X.Tick.Marker = pow2Ticks{} + p.X.Label.Text = "Connections Amount" + p.Y.Scale = plot.LogScale{} + p.Y.Tick.Marker = pow2Ticks{} + p.Y.Label.Text = "Value Size" + + gridData := newHeatMapGrid(plotType, records) + + // Use the YlGnBu color palette from ColorBrewer to match the original implementation. + colors, _ := brewer.GetPalette(brewer.TypeAny, "YlGnBu", 9) + pal := invertedPalette{colors} + h := plotter.NewHeatMap(gridData, pal) + + p.Title.Text = fmt.Sprintf("%s [%.2f, %.2f]", title, h.Min, h.Max) + p.Add(h) + + // Create a legend with the scale. + legend := generateScaleLegend(h.Min, h.Max, pal) + + return p, legend +} + +// plotDeltaHeatMap plots a heatmap for the delta between two sets of records. +func plotDeltaHeatMap(title, plotType string, records [][]dataset.DataRecord, zeroCentered bool) (*plot.Plot, plot.Legend) { + p := plot.New() + p.X.Scale = plot.LogScale{} + p.X.Tick.Marker = pow2Ticks{} + p.X.Label.Text = "Connections Amount" + p.Y.Scale = plot.LogScale{} + p.Y.Tick.Marker = pow2Ticks{} + p.Y.Label.Text = "Value Size" + + gridData := newDeltaHeatMapGrid(plotType, records) + + // Use the RdBu color palette from ColorBrewer to match the original implementation. + colors, _ := brewer.GetPalette(brewer.TypeAny, "RdBu", 11) + pal := invertedPalette{colors} + h := plotter.NewHeatMap(gridData, pal) + p.Title.Text = fmt.Sprintf("%s [%.2f%%, %.2f%%]", title, h.Min, h.Max) + + if zeroCentered { + if h.Min < 0 && math.Abs(h.Min) > h.Max { + h.Max = math.Abs(h.Min) + } else { + h.Min = h.Max * -1 + } + } + + p.Add(h) + + // Create a legend with the scale. + legend := generateScaleLegend(h.Min, h.Max, pal) + + return p, legend +} + +// generateScaleLegend generates legends for the heatmap. +func generateScaleLegend(min, max float64, pal palette.Palette) plot.Legend { + legend := plot.NewLegend() + thumbs := plotter.PaletteThumbnailers(pal) + step := (max - min) / float64(len(thumbs)-1) + for i := len(thumbs) - 1; i >= 0; i-- { + legend.Add(fmt.Sprintf("%.0f", min+step*float64(i)), thumbs[i]) + } + legend.Top = true + + return legend +} diff --git a/tools/rw-heatmaps/pkg/dataset/dataset.go b/tools/rw-heatmaps/pkg/dataset/dataset.go new file mode 100644 index 000000000000..b81b0bc203da --- /dev/null +++ b/tools/rw-heatmaps/pkg/dataset/dataset.go @@ -0,0 +1,122 @@ +// 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 dataset + +import ( + "encoding/csv" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +const ( + _ = iota + // fieldIndexRatio is the index of the ratio field in the CSV file. + fieldIndexRatio + // fieldIndexConnSize is the index of the connection size (connSize) field in the CSV file. + fieldIndexConnSize + // fieldIndexValueSize is the index of the value size (valueSize) field in the CSV file. + fieldIndexValueSize + // fieldIndexIterOffset is the index of the first iteration field in the CSV file. + fieldIndexIterOffset +) + +// DataSet holds the data for the heatmaps, including the parameter used for the run. +type DataSet struct { + // FileName is the name of the file from which the data was loaded. + FileName string + // Records is a map from the ratio of read to write operations to the data for that ratio. + Records map[float64][]DataRecord + // Param is the parameter used for the run. + Param string +} + +// DataRecord holds the data for a single heatmap chart. +type DataRecord struct { + ConnSize int64 + ValueSize int64 + Read float64 + Write float64 +} + +// GetSortedRatios returns the sorted ratios of read to write operations in the dataset. +func (d *DataSet) GetSortedRatios() []float64 { + ratios := make([]float64, 0) + for ratio := range d.Records { + ratios = append(ratios, ratio) + } + sort.Float64s(ratios) + return ratios +} + +// LoadCSVData loads the data from a CSV file into a DataSet. +func LoadCSVData(inputFile string) (*DataSet, error) { + file, err := os.Open(inputFile) + if err != nil { + return nil, err + } + defer file.Close() + + reader := csv.NewReader(file) + lines, err := reader.ReadAll() + if err != nil { + return nil, err + } + + records := make(map[float64][]DataRecord) + + // Count the number of iterations. + iters := 0 + for _, header := range lines[0][fieldIndexIterOffset:] { + if strings.HasPrefix(header, "iter") { + iters++ + } + } + + // Running parameters are stored in the first line after the header, after the iteration fields. + param := lines[1][fieldIndexIterOffset+iters] + + for _, line := range lines[2:] { // Skip header line. + ratio, _ := strconv.ParseFloat(line[fieldIndexRatio], 64) + if _, ok := records[ratio]; !ok { + records[ratio] = make([]DataRecord, 0) + } + connSize, _ := strconv.ParseInt(line[fieldIndexConnSize], 10, 64) + valueSize, _ := strconv.ParseInt(line[fieldIndexValueSize], 10, 64) + + // Calculate the average read and write values for the iterations. + var readSum, writeSum float64 + for _, v := range line[fieldIndexIterOffset : fieldIndexIterOffset+iters] { + splitted := strings.Split(v, ":") + + readValue, _ := strconv.ParseFloat(splitted[0], 64) + readSum += readValue + + writeValue, _ := strconv.ParseFloat(splitted[1], 64) + writeSum += writeValue + } + + records[ratio] = append(records[ratio], DataRecord{ + ConnSize: connSize, + ValueSize: valueSize, + Read: readSum / float64(iters), + Write: writeSum / float64(iters), + }) + } + + return &DataSet{FileName: filepath.Base(inputFile), Records: records, Param: param}, nil +} diff --git a/tools/rw-heatmaps/plot_data.py b/tools/rw-heatmaps/plot_data.py deleted file mode 100755 index 2e4cb238315f..000000000000 --- a/tools/rw-heatmaps/plot_data.py +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env python3 -import sys -import os -import argparse -import logging -import pandas as pd -import numpy as np -import matplotlib.pyplot as plt -import matplotlib.colors as colors - -logging.basicConfig(format='[%(levelname)s %(asctime)s %(name)s] %(message)s') -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) - -params = None - - -def parse_args(): - parser = argparse.ArgumentParser( - description='plot graph using mixed read/write result file.') - parser.add_argument('input_file_a', type=str, - help='first input data files in csv format. (required)') - parser.add_argument('input_file_b', type=str, nargs='?', - help='second input data files in csv format. (optional)') - parser.add_argument('-t', '--title', dest='title', type=str, required=True, - help='plot graph title string') - parser.add_argument('-z', '--zero-centered', dest='zero', action='store_true', required=False, - help='plot the improvement graph with white color represents 0.0', - default=True) - parser.add_argument('--no-zero-centered', dest='zero', action='store_false', required=False, - help='plot the improvement graph without white color represents 0.0') - parser.add_argument('-o', '--output-image-file', dest='output', type=str, required=True, - help='output image filename') - parser.add_argument('-F', '--output-format', dest='format', type=str, default='png', - help='output image file format. default: jpg') - return parser.parse_args() - - -def load_data_files(*args): - df_list = [] - try: - for i in args: - if i is not None: - logger.debug('loading csv file {}'.format(i)) - df_list.append(pd.read_csv(i)) - except FileNotFoundError as e: - logger.error(str(e)) - sys.exit(1) - res = [] - try: - for df in df_list: - param_df = df[df['type'] == 'PARAM'] - param_str = '' - if len(param_df) != 0: - param_str = param_df['comment'].iloc[0] - new_df = df[df['type'] == 'DATA'][[ - 'ratio', 'conn_size', 'value_size']].copy() - cols = [x for x in df.columns if x.find('iter') != -1] - tmp = [df[df['type'] == 'DATA'][x].str.split(':') for x in cols] - - read_df = [x.apply(lambda x: float(x[0])) for x in tmp] - read_avg = sum(read_df) / len(read_df) - new_df['read'] = read_avg - - write_df = [x.apply(lambda x: float(x[1])) for x in tmp] - write_avg = sum(write_df) / len(write_df) - new_df['write'] = write_avg - - new_df['ratio'] = new_df['ratio'].astype(float) - new_df['conn_size'] = new_df['conn_size'].astype(int) - new_df['value_size'] = new_df['value_size'].astype(int) - res.append({ - 'dataframe': new_df, - 'param': param_str - }) - except Exception as e: - logger.error(str(e)) - sys.exit(1) - return res - - -# plot type is the type of the data to plot. Either 'read' or 'write' -def plot_data(title, plot_type, cmap_name_default, *args): - if len(args) == 1: - fig_size = (12, 16) - df0 = args[0]['dataframe'] - df0param = args[0]['param'] - fig = plt.figure(figsize=fig_size) - count = 0 - for val, df in df0.groupby('ratio'): - count += 1 - plt.subplot(4, 2, count) - plt.tripcolor(df['conn_size'], df['value_size'], df[plot_type]) - plt.title('R/W Ratio {:.4f} [{:.2f}, {:.2f}]'.format(val, df[plot_type].min(), - df[plot_type].max())) - plt.yscale('log', base=2) - plt.ylabel('Value Size') - plt.xscale('log', base=2) - plt.xlabel('Connections Amount') - plt.colorbar() - plt.tight_layout() - fig.suptitle('{} [{}]\n{}'.format(title, plot_type.upper(), df0param)) - elif len(args) == 2: - fig_size = (12, 26) - df0 = args[0]['dataframe'] - df0param = args[0]['param'] - df1 = args[1]['dataframe'] - df1param = args[1]['param'] - fig = plt.figure(figsize=fig_size) - col = 0 - delta_df = df1.copy() - delta_df[[plot_type]] = ((df1[[plot_type]] - df0[[plot_type]]) / - df0[[plot_type]]) * 100 - for tmp in [df0, df1, delta_df]: - row = 0 - for val, df in tmp.groupby('ratio'): - pos = row * 3 + col + 1 - plt.subplot(8, 3, pos) - norm = None - if col == 2: - cmap_name = 'bwr' - if params.zero: - norm = colors.CenteredNorm() - else: - cmap_name = cmap_name_default - plt.tripcolor(df['conn_size'], df['value_size'], df[plot_type], - norm=norm, - cmap=plt.get_cmap(cmap_name)) - if row == 0: - if col == 0: - plt.title('{}\nR/W Ratio {:.4f} [{:.1f}, {:.1f}]'.format( - os.path.basename(params.input_file_a), - val, df[plot_type].min(), df[plot_type].max())) - elif col == 1: - plt.title('{}\nR/W Ratio {:.4f} [{:.1f}, {:.1f}]'.format( - os.path.basename(params.input_file_b), - val, df[plot_type].min(), df[plot_type].max())) - elif col == 2: - plt.title('Gain\nR/W Ratio {:.4f} [{:.2f}%, {:.2f}%]'.format(val, df[plot_type].min(), - df[plot_type].max())) - else: - if col == 2: - plt.title('R/W Ratio {:.4f} [{:.2f}%, {:.2f}%]'.format(val, df[plot_type].min(), - df[plot_type].max())) - else: - plt.title('R/W Ratio {:.4f} [{:.1f}, {:.1f}]'.format(val, df[plot_type].min(), - df[plot_type].max())) - plt.yscale('log', base=2) - plt.ylabel('Value Size') - plt.xscale('log', base=2) - plt.xlabel('Connections Amount') - - if col == 2: - plt.colorbar(format='%.2f%%') - else: - plt.colorbar() - plt.tight_layout() - row += 1 - col += 1 - fig.suptitle('{} [{}]\n{} {}\n{} {}'.format( - title, plot_type.upper(), os.path.basename(params.input_file_a), df0param, - os.path.basename(params.input_file_b), df1param)) - else: - raise Exception('invalid plot input data') - fig.subplots_adjust(top=0.93) - plt.savefig("{}_{}.{}".format(params.output, plot_type, - params.format), format=params.format) - - -def main(): - global params - logging.basicConfig() - params = parse_args() - result = load_data_files(params.input_file_a, params.input_file_b) - for i in [('read', 'viridis'), ('write', 'plasma')]: - plot_type, cmap_name = i - plot_data(params.title, plot_type, cmap_name, *result) - - -if __name__ == '__main__': - main() diff --git a/tools/rw-heatmaps/requirements.txt b/tools/rw-heatmaps/requirements.txt deleted file mode 100644 index 95842246d681..000000000000 --- a/tools/rw-heatmaps/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -matplotlib==3.7.1 -numpy==1.24.3 -pandas==2.0.1