In order to get a general idea of how k6 is performing, and to see if there are any low-hanging fruit in terms of optimizations we could do, I did a series of tests running k6 against a local server, testing different changes to the k6 code base.
macOS High Sierra 10.13.13
MacBook Pro(Retina, 15-inch, Mid 2014)
Processor: 2,2Ghz Intel Core i7
Memory: 16GB 1600MHz DDR3
Here is the code of the webserver I used.
package main
import (
"net/http"
)
func sayHello(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello"))
}
func main() {
http.HandleFunc("/", sayHello)
if err := http.ListenAndServe(":8000", nil); err != nil {
panic(err)
}
}
In order to get how fast this code can go, I used wrk
, the fastest load testing tool I could find.
➜ ~ wrk -t 4 -c 4 -d 10 http://localhost:8000
Running 10s test @ http://localhost:8000
4 threads and 4 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 86.31us 25.00us 1.93ms 88.16%
Req/Sec 10.95k 310.97 12.43k 85.15%
440269 requests in 10.10s, 50.80MB read
Requests/sec: 43591.55
Transfer/sec: 5.03MB
After running wrk, to get a benchmark of what's possible on the machine, I started the performance investigation of k6. I did 3 different tests with the following script:
import http from "k6/http";
export default function() {
http.get("http://127.0.0.1:8000");
}
The tests were:
- No changes to k6 code
- Changed the
http.request
method, given that it's the method where k6 spends the most time. - Created a new
Runner
that uses pure Go code, without anything related to Goja, the JavaScript implementation we use. To understand how much overhead Goja adds to k6.
/\ |‾‾| /‾‾/ /‾/
/\ / \ | |_/ / / /
/ \/ \ | | / ‾‾\
/ \ | |‾\ \ | (_) |
/ __________ \ |__| \__\ \___/ .io
execution: local
output: -
script: script.js
duration: 10s, iterations: -
vus: 4, max: 4
data_received..............: 20 MB 2.0 MB/s
data_sent..................: 14 MB 1.3 MB/s
http_req_blocked...........: avg=2.18µs min=741ns med=1.68µs max=1.58ms p(90)=2.52µs p(95)=3.51µs
http_req_connecting........: avg=7ns min=0s med=0s max=319.79µs p(90)=0s p(95)=0s
http_req_duration..........: avg=157.36µs min=63.55µs med=137.32µs max=9.71ms p(90)=214.66µs p(95)=262.45µs
http_req_receiving.........: avg=30.85µs min=9.77µs med=24.73µs max=9.4ms p(90)=45.69µs p(95)=57.42µs
http_req_sending...........: avg=13.11µs min=4.66µs med=9.92µs max=3.63ms p(90)=20.78µs p(95)=27.38µs
http_req_tls_handshaking...: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...........: avg=113.39µs min=41.27µs med=98.64µs max=7.65ms p(90)=154.87µs p(95)=187.85µs
http_reqs..................: 166497 16649.62556/s
iteration_duration.........: avg=223.02µs min=93.76µs med=193.26µs max=12.34ms p(90)=310.16µs p(95)=378.89µs
iterations.................: 166496 16649.52556/s
vus........................: 4 min=4 max=4
vus_max....................: 4 min=4 max=4
Here is the much simpler http.request
implementation, compared to the original one:
func (h *HTTP) request(ctx context.Context, rt *goja.Runtime, state *common.State, method string, url URL, args ...goja.Value) (*HTTPResponse, []stats.Sample, error) {
req := &http.Request{
Method: "GET",
URL: url.URL,
}
client := http.Client{
Transport: state.HTTPTransport,
}
tracer := netext.Tracer{}
resp, err := client.Do(req.WithContext(netext.WithTracer(ctx, &tracer)))
if err != nil {
return nil, nil, err
}
io.Copy(ioutil.Discard, resp.Body)
resp.Body.Close()
trail := tracer.Done()
return &HTTPResponse{ctx: ctx}, trail.Samples(map[string]string{}), nil
}
/\ |‾‾| /‾‾/ /‾/
/\ / \ | |_/ / / /
/ \/ \ | | / ‾‾\
/ \ | |‾\ \ | (_) |
/ __________ \ |__| \__\ \___/ .io
execution: local
output: -
script: script.js
duration: 10s, iterations: -
vus: 4, max: 4
data_received..............: 23 MB 2.3 MB/s
data_sent..................: 14 MB 1.4 MB/s
http_req_blocked...........: avg=2.16µs min=803ns med=1.64µs max=11.38ms p(90)=2.47µs p(95)=3.38µs
http_req_connecting........: avg=7ns min=0s med=0s max=371.46µs p(90)=0s p(95)=0s
http_req_duration..........: avg=152.56µs min=51.51µs med=134.11µs max=10.59ms p(90)=206.94µs p(95)=253.12µs
http_req_receiving.........: avg=28.04µs min=8.08µs med=22.78µs max=9.91ms p(90)=41.74µs p(95)=52.15µs
http_req_sending...........: avg=12.71µs min=4.14µs med=9.61µs max=10.06ms p(90)=20.46µs p(95)=26.77µs
http_req_tls_handshaking...: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...........: avg=111.8µs min=37.89µs med=97.52µs max=9.3ms p(90)=153.8µs p(95)=187.86µs
http_reqs..................: 187513 18750.704568/s
iteration_duration.........: avg=194.24µs min=72.1µs med=170.76µs max=11.57ms p(90)=264.4µs p(95)=323.58µs
iterations.................: 187509 18750.30458/s
vus........................: 4 min=4 max=4
vus_max....................: 4 min=4 max=4
Here is the code of the Runner that executes without using Goja:
func (vu PerfRunnerVU) RunOnce(ctx context.Context) ([]stats.Sample, error) {
uri, _ := url.Parse("http://127.0.0.1:8000")
req := &http.Request{
Method: "GET",
URL: uri,
}
client := http.Client{
Transport: vu.HTTPTransport,
}
tracer := netext.Tracer{}
resp, err := client.Do(req.WithContext(netext.WithTracer(ctx, &tracer)))
if err != nil {
return nil, err
}
io.Copy(ioutil.Discard, resp.Body)
resp.Body.Close()
trail := tracer.Done()
return trail.Samples(map[string]string{}), nil
}
/\ |‾‾| /‾‾/ /‾/
/\ / \ | |_/ / / /
/ \/ \ | | / ‾‾\
/ \ | |‾\ \ | (_) |
/ __________ \ |__| \__\ \___/ .io
execution: local
output: -
script: script.js
duration: 10s, iterations: -
vus: 4, max: 4
http_req_blocked...........: avg=2.06µs min=640ns med=1.65µs max=4.58ms p(90)=2.4µs p(95)=3.3µs
http_req_connecting........: avg=12ns min=0s med=0s max=784.64µs p(90)=0s p(95)=0s
http_req_duration..........: avg=141.6µs min=52.39µs med=127.53µs max=7.82ms p(90)=186.78µs p(95)=225.36µs
http_req_receiving.........: avg=26.04µs min=7.03µs med=22.02µs max=7.7ms p(90)=38.29µs p(95)=46.5µs
http_req_sending...........: avg=11.07µs min=3.51µs med=8.61µs max=2.79ms p(90)=17.84µs p(95)=22.93µs
http_req_tls_handshaking...: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...........: avg=104.48µs min=38.23µs med=93.41µs max=5.9ms p(90)=140.32µs p(95)=167.8µs
http_reqs..................: 237082 23708.072984/s
iterations.................: 237080 23707.872985/s
vus........................: 4 min=4 max=4
vus_max....................: 4 min=4 max=4
As we can see, Goja adds some significant overhead, but that is a trade-off we need to accept in order for k6 to be able to run JavaScript.
To compare k6
with a HTTP benchmarking tool written in Go, I ran bombardier against the server using the standard HTTP client and the fasthttp lib, an HTTP library tuned for high performance.
bombardier --http1 -c 4 -d 10s -l http://127.0.0.1:8000
Bombarding http://127.0.0.1:8000 for 10s using 4 connection(s)
[========================================================] 10s
Done!
Statistics Avg Stdev Max
Reqs/sec 26780.95 1420.26 29373.67
Latency 146.91us 32.82us 6.26ms
Latency Distribution
50% 140.00us
75% 156.00us
90% 177.00us
95% 197.00us
99% 320.00us
HTTP codes:
1xx - 0, 2xx - 267770, 3xx - 0, 4xx - 0, 5xx - 0
others - 0
Throughput: 5.51MB/s
bombardier --fasthttp -c 4 -d 10s -l http://127.0.0.1:8000
Bombarding http://127.0.0.1:8000 for 10s using 4 connection(s)
[========================================================] 10s
Done!
Statistics Avg Stdev Max
Reqs/sec 42227.74 1556.34 48234.90
Latency 92.62us 16.00us 4.72ms
Latency Distribution
50% 89.00us
75% 104.00us
90% 120.00us
95% 131.00us
99% 160.00us
HTTP codes:
1xx - 0, 2xx - 422172, 3xx - 0, 4xx - 0, 5xx - 0
others - 0
Throughput: 7.37MB/s
As you can see, the fasthttp
lib is way faster, but unfortunately we can't use it because it doesn't support tracing or HTTP2.