From 2823e3e9e022b84e118b424bc9ba413e9b3ea5fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Senart?= Date: Sat, 25 Aug 2018 00:23:44 +0200 Subject: [PATCH 1/3] results: Use github.com/mailru/easyjson for JSON encoding It's much faster. --- Gopkg.lock | 16 ++++ Gopkg.toml | 4 + lib/results.go | 28 +++++-- lib/results_easyjson.go | 164 ++++++++++++++++++++++++++++++++++++++++ lib/results_test.go | 68 ++++++++++++++--- 5 files changed, 266 insertions(+), 14 deletions(-) create mode 100644 lib/results_easyjson.go diff --git a/Gopkg.lock b/Gopkg.lock index e525a87d..b6d17b34 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -62,6 +62,19 @@ pruneopts = "UT" revision = "a7d76c6f093a59b94a01c6c2b8429122d444a8cc" +[[projects]] + branch = "master" + digest = "1:aa3d8d42865c42626b5c1add193692d045b3188b1479f0a0a88690d21fe20083" + name = "github.com/mailru/easyjson" + packages = [ + ".", + "buffer", + "jlexer", + "jwriter", + ] + pruneopts = "UT" + revision = "60711f1a8329503b04e1c88535f419d0bb440bff" + [[projects]] branch = "master" digest = "1:7ca2584fa7da0520cd2d1136a10194fe5a5b220bdb215074ab6f7b5ad91115f4" @@ -144,6 +157,9 @@ "github.com/dgryski/go-lttb", "github.com/google/go-cmp/cmp", "github.com/influxdata/tdigest", + "github.com/mailru/easyjson", + "github.com/mailru/easyjson/jlexer", + "github.com/mailru/easyjson/jwriter", "github.com/shurcooL/vfsgen", "github.com/streadway/quantile", "github.com/tsenart/go-tsz", diff --git a/Gopkg.toml b/Gopkg.toml index 7ee2ae03..1794f102 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -68,3 +68,7 @@ [[constraint]] branch = "master" name = "github.com/c2h5oh/datasize" + +[[constraint]] + branch = "master" + name = "github.com/mailru/easyjson" diff --git a/lib/results.go b/lib/results.go index 56e89bd8..2754cb51 100644 --- a/lib/results.go +++ b/lib/results.go @@ -1,15 +1,18 @@ package vegeta import ( + "bufio" "bytes" "encoding/base64" "encoding/csv" "encoding/gob" - "encoding/json" "io" "sort" "strconv" "time" + + "github.com/mailru/easyjson/jlexer" + jwriter "github.com/mailru/easyjson/jwriter" ) func init() { @@ -212,12 +215,27 @@ func NewCSVDecoder(rd io.Reader) Decoder { // NewJSONEncoder returns an Encoder that dumps the given *Results as a JSON // object. func NewJSONEncoder(w io.Writer) Encoder { - enc := json.NewEncoder(w) - return func(r *Result) error { return enc.Encode(r) } + var jw jwriter.Writer + return func(r *Result) error { + (*jsonResult)(r).encode(&jw) + if jw.Error != nil { + return jw.Error + } + jw.RawByte('\n') + _, err := jw.DumpTo(w) + return err + } } // NewJSONDecoder returns a Decoder that decodes JSON encoded Results. func NewJSONDecoder(r io.Reader) Decoder { - dec := json.NewDecoder(r) - return func(r *Result) error { return dec.Decode(r) } + rd := bufio.NewReader(r) + return func(r *Result) (err error) { + var jl jlexer.Lexer + if jl.Data, err = rd.ReadSlice('\n'); err != nil { + return err + } + (*jsonResult)(r).decode(&jl) + return jl.Error() + } } diff --git a/lib/results_easyjson.go b/lib/results_easyjson.go new file mode 100644 index 00000000..17f10923 --- /dev/null +++ b/lib/results_easyjson.go @@ -0,0 +1,164 @@ +// This file has been modified from the original generated code to make it work with +// type alias jsonResult so that the methods aren't exposed in Result. +package vegeta + +import ( + "time" + + "github.com/mailru/easyjson/jlexer" + "github.com/mailru/easyjson/jwriter" +) + +type jsonResult Result + +func (out *jsonResult) decode(in *jlexer.Lexer) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeString() + in.WantColon() + if in.IsNull() { + in.Skip() + in.WantComma() + continue + } + switch key { + case "attack": + out.Attack = string(in.String()) + case "seq": + out.Seq = uint64(in.Uint64()) + case "code": + out.Code = uint16(in.Uint16()) + case "timestamp": + if data := in.Raw(); in.Ok() { + in.AddError((out.Timestamp).UnmarshalJSON(data)) + } + case "latency": + out.Latency = time.Duration(in.Int64()) + case "bytes_out": + out.BytesOut = uint64(in.Uint64()) + case "bytes_in": + out.BytesIn = uint64(in.Uint64()) + case "error": + out.Error = string(in.String()) + case "body": + if in.IsNull() { + in.Skip() + out.Body = nil + } else { + out.Body = in.Bytes() + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} + +func (in *jsonResult) encode(out *jwriter.Writer) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"attack\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.String(string(in.Attack)) + } + { + const prefix string = ",\"seq\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Uint64(uint64(in.Seq)) + } + { + const prefix string = ",\"code\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Uint16(uint16(in.Code)) + } + { + const prefix string = ",\"timestamp\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Raw((in.Timestamp).MarshalJSON()) + } + { + const prefix string = ",\"latency\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Int64(int64(in.Latency)) + } + { + const prefix string = ",\"bytes_out\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Uint64(uint64(in.BytesOut)) + } + { + const prefix string = ",\"bytes_in\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Uint64(uint64(in.BytesIn)) + } + { + const prefix string = ",\"error\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.String(string(in.Error)) + } + { + const prefix string = ",\"body\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Base64Bytes(in.Body) + } + out.RawByte('}') +} diff --git a/lib/results_test.go b/lib/results_test.go index ee4ca624..2c7b07e1 100644 --- a/lib/results_test.go +++ b/lib/results_test.go @@ -3,6 +3,7 @@ package vegeta import ( "bytes" "io" + "math/rand" "reflect" "testing" "testing/quick" @@ -79,19 +80,23 @@ func TestEncoding(t *testing.T) { var buf bytes.Buffer enc := tc.enc(&buf) - if err := enc(&want); err != nil { - t.Fatal(err) + for j := 0; j < 2; j++ { + if err := enc(&want); err != nil { + t.Fatal(err) + } } dec := tc.dec(&buf) - var got Result - if err := dec(&got); err != nil { - t.Fatalf("err: %q buffer: %s", err, buf.String()) - } + for j := 0; j < 2; j++ { + var got Result + if err := dec(&got); err != nil { + t.Fatalf("err: %q buffer: %s", err, buf.String()) + } - if !got.Equal(want) { - t.Logf("\ngot: %#v\nwant: %#v\n", got, want) - return false + if !got.Equal(want) { + t.Logf("\ngot: %#v\nwant: %#v\n", got, want) + return false + } } return true @@ -103,3 +108,48 @@ func TestEncoding(t *testing.T) { }) } } + +func BenchmarkEncodings(b *testing.B) { + b.StopTimer() + b.ResetTimer() + + rng := rand.New(rand.NewSource(0)) + zf := rand.NewZipf(rng, 3, 2, 1000) + began := time.Now() + results := make([]Result, 1e5) + + for i := 0; i < cap(results); i++ { + results[i] = Result{ + Attack: "Big Bang!", + Seq: uint64(i), + Timestamp: began.Add(time.Duration(i) * time.Millisecond), + Latency: time.Duration(zf.Uint64()) * time.Millisecond, + } + } + + for _, tc := range []struct { + encoding string + enc func(io.Writer) Encoder + dec func(io.Reader) Decoder + }{ + {"gob", NewEncoder, NewDecoder}, + {"csv", NewCSVEncoder, NewCSVDecoder}, + {"json", NewJSONEncoder, NewJSONDecoder}, + } { + var buf bytes.Buffer + enc := tc.enc(&buf) + + b.Run(tc.encoding+"-encode", func(b *testing.B) { + for i := 0; i < b.N; i++ { + enc.Encode(&results[i%len(results)]) + } + }) + + dec := tc.dec(&buf) + b.Run(tc.encoding+"-decode", func(b *testing.B) { + for i := 0; i < b.N; i++ { + dec.Decode(&results[i%len(results)]) + } + }) + } +} From 8a05c7707833c146622af886a7cc073bc6c2dd00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Senart?= Date: Sat, 25 Aug 2018 11:10:39 +0200 Subject: [PATCH 2/3] targets: Use github.com/mailru/easyjson ``` benchmark old ns/op new ns/op delta BenchmarkJSONTargetEncoding/encode-8 787 781 -0.76% BenchmarkJSONTargetEncoding/decode-8 5812 1510 -74.02% benchmark old allocs new allocs delta BenchmarkJSONTargetEncoding/encode-8 1 1 +0.00% BenchmarkJSONTargetEncoding/decode-8 18 9 -50.00% benchmark old bytes new bytes delta BenchmarkJSONTargetEncoding/encode-8 470 470 +0.00% BenchmarkJSONTargetEncoding/decode-8 1230 703 -42.85% ``` --- lib/results_easyjson.go | 2 +- lib/results_test.go | 6 +- lib/targets.go | 49 +++++++++--- lib/targets_easyjson.go | 167 ++++++++++++++++++++++++++++++++++++++++ lib/targets_test.go | 31 ++++++++ 5 files changed, 242 insertions(+), 13 deletions(-) create mode 100644 lib/targets_easyjson.go diff --git a/lib/results_easyjson.go b/lib/results_easyjson.go index 17f10923..b763ecb4 100644 --- a/lib/results_easyjson.go +++ b/lib/results_easyjson.go @@ -66,7 +66,7 @@ func (out *jsonResult) decode(in *jlexer.Lexer) { } } -func (in *jsonResult) encode(out *jwriter.Writer) { +func (in jsonResult) encode(out *jwriter.Writer) { out.RawByte('{') first := true _ = first diff --git a/lib/results_test.go b/lib/results_test.go index 2c7b07e1..b53838f8 100644 --- a/lib/results_test.go +++ b/lib/results_test.go @@ -10,7 +10,7 @@ import ( "time" ) -func TestDecoding(t *testing.T) { +func TestResultDecoding(t *testing.T) { t.Parallel() var b1, b2 bytes.Buffer @@ -48,7 +48,7 @@ func TestDecoding(t *testing.T) { } } -func TestEncoding(t *testing.T) { +func TestResultEncoding(t *testing.T) { for _, tc := range []struct { encoding string enc func(io.Writer) Encoder @@ -109,7 +109,7 @@ func TestEncoding(t *testing.T) { } } -func BenchmarkEncodings(b *testing.B) { +func BenchmarkResultEncodings(b *testing.B) { b.StopTimer() b.ResetTimer() diff --git a/lib/targets.go b/lib/targets.go index c207548d..4f735d76 100644 --- a/lib/targets.go +++ b/lib/targets.go @@ -3,7 +3,6 @@ package vegeta import ( "bufio" "bytes" - "encoding/json" "errors" "fmt" "io" @@ -14,6 +13,9 @@ import ( "strings" "sync" "sync/atomic" + + jlexer "github.com/mailru/easyjson/jlexer" + jwriter "github.com/mailru/easyjson/jwriter" ) // Target is an HTTP request blueprint. @@ -103,6 +105,11 @@ const ( // Implementations must be safe for concurrent use. type Targeter func(*Target) error +// Decode is a convenience method that calls the underlying Targeter function. +func (tr Targeter) Decode(t *Target) error { + return tr(t) +} + // NewJSONTargeter returns a new targeter that decodes one Target from the // given io.Reader on every invocation. Each target is one JSON object in its own line. // @@ -127,16 +134,16 @@ func NewJSONTargeter(src io.Reader, body []byte, header http.Header) Targeter { return ErrNilTarget } - rd.Lock() - defer rd.Unlock() + var jl jlexer.Lexer - var line []byte - for len(line) == 0 { - if line, err = rd.ReadBytes('\n'); err != nil { + rd.Lock() + for len(jl.Data) == 0 { + if jl.Data, err = rd.ReadBytes('\n'); err != nil { break } - line = bytes.TrimSpace(line) // Skip empty lines + jl.Data = bytes.TrimSpace(jl.Data) // Skip empty lines } + rd.Unlock() if err != nil { if err == io.EOF { @@ -145,8 +152,10 @@ func NewJSONTargeter(src io.Reader, body []byte, header http.Header) Targeter { return err } - var t Target - if err = json.Unmarshal(line, &t); err != nil { + var t jsonTarget + t.decode(&jl) + + if err = jl.Error(); err != nil { return err } else if t.Method == "" { return ErrNoMethod @@ -176,6 +185,28 @@ func NewJSONTargeter(src io.Reader, body []byte, header http.Header) Targeter { } } +// A TargetEncoder encodes a Target in a format that can be read by a Targeter. +type TargetEncoder func(*Target) error + +// Encode is a convenience method that calls the underlying TargetEncoder function. +func (enc TargetEncoder) Encode(t *Target) error { + return enc(t) +} + +// NewJSONTargetEncoder returns a TargetEncoder that encods Targets in the JSON format. +func NewJSONTargetEncoder(w io.Writer) TargetEncoder { + var jw jwriter.Writer + return func(t *Target) error { + (*jsonTarget)(t).encode(&jw) + if jw.Error != nil { + return jw.Error + } + jw.RawByte('\n') + _, err := jw.DumpTo(w) + return err + } +} + // NewStaticTargeter returns a Targeter which round-robins over the passed // Targets. func NewStaticTargeter(tgts ...Target) Targeter { diff --git a/lib/targets_easyjson.go b/lib/targets_easyjson.go new file mode 100644 index 00000000..2d14b1a5 --- /dev/null +++ b/lib/targets_easyjson.go @@ -0,0 +1,167 @@ +// This file has been modified from the original generated code to make it work with +// type alias jsonTarget so that the methods aren't exposed in Target. + +package vegeta + +import ( + http "net/http" + + jlexer "github.com/mailru/easyjson/jlexer" + jwriter "github.com/mailru/easyjson/jwriter" +) + +type jsonTarget Target + +func (out *jsonTarget) decode(in *jlexer.Lexer) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeString() + in.WantColon() + if in.IsNull() { + in.Skip() + in.WantComma() + continue + } + switch key { + case "method": + out.Method = string(in.String()) + case "url": + out.URL = string(in.String()) + case "body": + if in.IsNull() { + in.Skip() + out.Body = nil + } else { + out.Body = in.Bytes() + } + case "header": + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + out.Header = make(http.Header) + } else { + out.Header = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v2 []string + if in.IsNull() { + in.Skip() + v2 = nil + } else { + in.Delim('[') + if v2 == nil { + if !in.IsDelim(']') { + v2 = make([]string, 0, 4) + } else { + v2 = []string{} + } + } else { + v2 = (v2)[:0] + } + for !in.IsDelim(']') { + var v3 string + v3 = string(in.String()) + v2 = append(v2, v3) + in.WantComma() + } + in.Delim(']') + } + (out.Header)[key] = v2 + in.WantComma() + } + in.Delim('}') + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} + +func (in jsonTarget) encode(out *jwriter.Writer) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"method\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.String(string(in.Method)) + } + { + const prefix string = ",\"url\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.String(string(in.URL)) + } + if len(in.Body) != 0 { + const prefix string = ",\"body\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + out.Base64Bytes(in.Body) + } + if len(in.Header) != 0 { + const prefix string = ",\"header\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + { + out.RawByte('{') + v6First := true + for v6Name, v6Value := range in.Header { + if v6First { + v6First = false + } else { + out.RawByte(',') + } + out.String(string(v6Name)) + out.RawByte(':') + if v6Value == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { + out.RawString("null") + } else { + out.RawByte('[') + for v7, v8 := range v6Value { + if v7 > 0 { + out.RawByte(',') + } + out.String(string(v8)) + } + out.RawByte(']') + } + } + out.RawByte('}') + } + } + out.RawByte('}') +} diff --git a/lib/targets_test.go b/lib/targets_test.go index b3527f36..0c24e81a 100644 --- a/lib/targets_test.go +++ b/lib/targets_test.go @@ -389,3 +389,34 @@ func TestErrNilTarget(t *testing.T) { } } } + +func BenchmarkJSONTargetEncoding(b *testing.B) { + b.StopTimer() + b.ResetTimer() + + targets := make([]Target, 1e5) + for i := 0; i < cap(targets); i++ { + targets[i] = Target{ + Method: "POST", + URL: "https://goku/12345", + Body: []byte("BIG BANG!"), + Header: http.Header{"Content-Type": []string{"high/energy"}}, + } + } + + var buf bytes.Buffer + enc := NewJSONTargetEncoder(&buf) + + b.Run("encode", func(b *testing.B) { + for i := 0; i < b.N; i++ { + enc.Encode(&targets[i%len(targets)]) + } + }) + + dec := NewJSONTargeter(&buf, nil, nil) + b.Run("decode", func(b *testing.B) { + for i := 0; i < b.N; i++ { + dec.Decode(&targets[i%len(targets)]) + } + }) +} From 7729067e50c0213fb2b55335634a5526e06dc9bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Senart?= Date: Sat, 25 Aug 2018 11:17:11 +0200 Subject: [PATCH 3/3] targets: Adjust expected error in bad Base64 body encoding test --- lib/targets_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/targets_test.go b/lib/targets_test.go index 0c24e81a..e6a06cb1 100644 --- a/lib/targets_test.go +++ b/lib/targets_test.go @@ -119,7 +119,7 @@ func TestJSONTargeter(t *testing.T) { src: target(`{"method": "GET", "url": "http://goku", "body": "NOT BASE64"}`), in: &Target{}, out: &Target{}, - err: errors.New("illegal base64 data at input byte 3"), + err: errors.New("parse error: illegal base64 data at input byte 3 near offset 0 of ''"), }, { name: "default body",