The backend s3 is envelopes, and each envelope is a pipeline of builtins. The signature of AWS is 26 runs of printf, sed, cut, sha256sum and hmac -K in sh -c, and the answer of a PROPFIND changes a list of 1,000 keys with one filter of jq. A Worker of Workers Paid can send 10,000 subrequests for each request, so a FIFO can send a request for each key of a list. Then a builtin runs many times for one Request, and its speed is the speed of the backend. So I measured the builtins, and made them faster.
Three places to measure
scripts/bench.exs runs each case in the shell of mkfifo.com, in the VM on my computer, and gives the time, and the steps and the runs that the case takes from the work of a Request. scripts/bench.mjs sends the same cases to //bin/sh of the Worker in wrangler dev, where the VM runs in WebAssembly, as on Cloudflare. With --live, it sends them to mkfifo.com itself, after the deploy: at most 30 requests in a minute, and the median of 5 requests for each number.
The Worker was harder to measure. An HTTP request takes about 30 ms there, much more than a small case. In a Worker, the clock does not move while the code runs, so the VM cannot measure itself. So a request runs the script of a case on many lines, and the time of one run is the difference of many lines and one line, divided by the number of lines less one. The query of a request can be about 8 KB, so the long script of the signature fits only 5 times, and its time changes by about 1 ms from run to run. The rate limit of /bin/sh stopped my first run, so the script now sends at most 50 requests in a minute, and waits when it gets 429.
The two places do not agree. In the Worker, the code of Erlang is about 3 times slower. A NIF is faster there: the VM on my computer runs a NIF of WebAssembly in an interpreter, and V8 compiles it. scrypt of rclone takes 1.7 s on my computer and about 0.2 s in the Worker. So I measure a NIF only in the Worker.
The parse was the work
The profile of the signature showed little work in the builtins. Most of the time went to the parse of the commands, again for each Request. The filter of jq that checks the configuration of s3 (872 bytes) took 272 µs to parse, and 10 µs to run. The parse of a text is always the same, so a process now keeps its last parses: of templates, of scripts, of filters of jq and of formats of printf, at most 32 of each.
Two smaller things were in the same profile. The shell split each command into words to look for set, unset and exit, and String.split/1 makes a pattern each time. Now only a command that starts with one of them is split. Each ~r of Elixir imports its regular expression again on each use, so the shell reads the name of a variable with patterns of binaries.
jq, step by step
The list of 1,000 keys took 450,000 steps of jq, and 0.8 s on my computer.
- Each step took one step from the work of the Request, with four operations on counters. Now a run takes 1,000 steps at a time, each step is one operation on an atomic, and the run gives back the steps that it did not use. A test gives a run exactly the steps that it needs, then one step less, so I know that the limit stays exact.
capturecompiled its regular expression for each key. A run now keeps them.captureis also in the code now, not in the language of jq, with the outputs and the errors of jq 1.7.- jq gives the offsets of a match in characters. The count was a loop with a float, and a float is a new term at each byte. Now the count is an integer, and a search in C finds the first byte over 127 first: each byte before it is a character. That search helped most in the Worker, where a loop of Erlang over each byte is slow. The same search finds a string of JSON that needs no escape.
- The program of
s3had sixgsubfor the entities of XML, andwith_entriesto remove a null date. Onegsubwith an object does the same work, and a directory gets its date only when it has one: 264,000 steps in place of 450,000.
The 41,897 cases of jq in scripts/builtins.sh still give the outputs of jq 1.7, and more cases for capture, tonumber and the strings of JSON too.
The numbers
| Case | Before, VM | After, VM | Before, Worker | After, Worker | After, mkfifo.com |
|---|---|---|---|---|---|
| Signature of AWS, in a header field | 2.2 ms | 0.56 ms | 7.8 ms | 1 to 4 ms | 2.4 ms |
List of 1,000 keys of S3, as rclone lsjson | 803 ms | 162 ms | 1,452 ms | 460 to 500 ms | 341 ms |
jq with one value | 0.14 ms | 0.012 ms | 0.37 ms | 0.03 to 0.13 ms | 0.08 ms |
hmac -K of 64 bytes | 0.11 ms | 0.021 ms | 0.23 ms | 0.03 to 0.19 ms | 0.03 ms |
| scrypt of rclone | 2.0 s | 1.7 s | 0.17 s | 0.17 to 0.22 s | 0.11 s |
The small cases in the Worker are ranges of three runs, because of the time of HTTP. The NIFs did not change: scrypt only shows the places.
On mkfifo.com, the numbers are of one run. Cloudflare is faster than wrangler dev on my computer, by about 1.5 to 2 times: base64 and basenc of 512 KiB give 28 MB/s there, and gzip of 1 MiB gives 113 MB/s. For sha256sum and secretbox of a large body, the time of the upload changes more than the time of the work, so I measure them only in wrangler dev.
One rule
Each of these changes made a known builtin faster for each FIFO, and none of them is a builtin for s3. That is the rule now: a builtin is a tool that people know, with its usual options, and a FIFO joins the builtins. When a FIFO needs a step that no builtin does, a builtin gets the option of a known tool, as hmac -K got the key in hexadecimal of openssl.