After creating my REST queuing service as a Mirage unikernel, I reported that it could serve the data at 2.46 MB/s from my ARM CubieTruck dev board.
That's fast enough for my use (it's faster than my Internet connection), but I was curious why it was slower than the Linux guest, which serves files with nc
at 20 MB/s.
( this post also appeared on Hacker News and Reddit )
Table of Contents
- The TCP test-case
- Compiler optimisations
- Profiling support
- Profiling the console
- Profiling UDP
- Profiling TCP
- Profiling disk access
- Profiling the queuing service
- Conclusions
The TCP test-case
To avoid confusing things by testing the disk and the network at the same time, I made a simpler test case that waits for a TCP connection and transmits a pre-allocated buffer multiple times:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
Profile.time
just runs the function and returns how long it took in seconds.
I do a few warm-up iterations at the start because TCP starts slowly and we don't want to benchmark that.
Compiler optimisations
While looking at the assembler output during some earlier debugging, I'd noticed that gcc was generating very poor code. For example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
gcc is using registers very inefficiently here.
For example, it stores r1
to [fp, #-44]
and then a few lines later loads from there into r0
, when it could just have moved it directly.
The last two lines show it saving r0
to the stack and then immediately loading it back again into the same register!
The fix here turned out to be simple.
Mini-OS by default compiles in debug mode with no optimisations.
Compiling with debug=n
fixes this, and I updated mirage-xen-minios to do this.
Optimisations | TCP download speed |
---|---|
none | 6.92 MB/s |
-O3 | 11.93 MB/s |
Even though Mirage is almost all OCaml, it does use Mini-OS's C functions for various low-level operations and these optimisations make a big difference!
Profiling support
The OCaml compiler provides a profiling option, which works the same way as gcc's -pg
option for C code.
To enable it, you add true: profile
to your _tags
file and rebuild.
I decided to see what would happen if I enabled this for my Xen unikernel:
_build/main.native.o: In function `caml_program':
:(.text+0x2): undefined reference to `__gnu_mcount_nc'
Profiling works by inserting a call to __gnu_mcount_nc
at the start of every function.
It looks like this:
1 2 3 4 |
|
The __gnu_mcount_nc
function gets the address of the callee function (caml_program
in this example) in the link register (lr
/r14
) and the address of its caller on the stack (pushed by the code fragment above).
Normally, the profiler would use this information to build up a static call graph (saying which functions call which other functions).
Using a regular timer interrupt to sample the program counter it can estimate how much time was spent in each function,
and using the call graph it can show cumulative totals (time spent in each function plus time spent in its children).
I decided to start with something a bit simpler.
I wrote some ARM code for __gnu_mcount_nc
that simply writes the caller, callee and current time to a trace buffer (when the buffer is full, it stops tracing).
Ideally, I'd like to get notified each time we leave a function too.
gcc can do that for C code with its -finstrument-functions
option, but I didn't see an option for that in OCaml.
Instead, I assume that every function runs until I see a call whose caller is not its parent.
This works surprisingly well, though it does mean that if a function seems to take a long time you need to check its parents too,
and it might get confused for recursive calls.
Also, for tail calls, we see the parent as the function we will return to rather than the function that actually called us.
At the end, I dump out the trace buffer to the console with some OCaml code. Back on my laptop, I wrote some code to parse this output and look up each address in the ELF image to get the function name for each address. (This code isn't public yet as it needs a lot of cleaning up.)
One thing I quickly discovered: compiling just the unikernel with profiling isn't sufficient. As soon as you call a non-profiled function it can no longer construct the call graph and the results are useless. I manually recompiled every C and OCaml library I was using with profiling, which was quite tedious.
Update: Thomas Gazagnaire has added an OPAM profiling switch which should make this much easier in future.
Initially, the trace buffer filled up almost instantly with calls to stub_evtchn_test_and_clear
.
It seems that we call this once for each of the 4096 channels every time we look for work.
To avoid clutter, I reduced the number of event channels to 10 (this had no noticeable effect on performance).
I also tried removing the memset
which zeroes out newly allocated IO pages.
This also made no difference.
I measured the overhead added by the tracing, both when compiled in but inactive and when actively writing to the trace buffer:
So, not too bad.
Profiling the console
The TCP code's trace was quite complicated, so I decided to start by profiling the much simpler console device, which I'd noticed was surprisingly slow at dumping the trace results.
A Xen virtual console is a pair of ring buffers (one for input from the keyboard, one for output to the screen) in a shared memory page, defined like this:
1 2 3 4 5 6 |
|
We're only interested in the "out" side here.
The producer (i.e. our unikernel) writes the data to the buffer and advances the out_prod
counter.
The consumer (xenconsoled
, running in Dom0) reads the data and advances out_cons
.
If the consumer catches up with the producer it sleeps until the producer notifies it there is more data.
If the producer catches up with the consumer (the buffer is full) it sleeps until the consumer notifies it there is space available again.
Here's my console test-case - writing a string to the console in a loop:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
I got around 45 KB/s. The trace output looked like this:
Start | Function | Duration |
---|---|---|
118684 | - - camlUnikernel__loop_1270 | 219 |
118730 | - - > camlConsole__write_all_low_1123 | 158 |
118737 | - - > - camlRing__repeat_1279 | 89 |
118739 | - - > - - camlRing__write_1270 | 87 |
118830 | - - > - camlEventchn__fun_1138 | 58 |
118831 | - - > - - stub_evtchn_notify | 57 |
118907 | - - camlUnikernel__loop_1270 | 211 |
118944 | - - > camlConsole__write_all_low_1123 | 168 |
118951 | - - > - camlRing__repeat_1279 | 100 |
118953 | - - > - - camlRing__write_1270 | 98 |
119054 | - - > - camlEventchn__fun_1138 | 58 |
119055 | - - > - - stub_evtchn_notify | 57 |
The start time and duration are measured in counter clock ticks, and the counter is running at 24 MHz.
The -->-->-->
indicates the level of nesting (I vary the character to make it easier to scan vertically with the eye).
The output shows two iterations of the loop taken from the middle of the sample.
To make the output more readable, my analysis script prunes the tree at calls that took less than 50 ticks, and removes calls to the Lwt library (while still showing the functions they called as a result).
The durations include the times for their children (including pruned children).
You can see that on each iteration we call Console.write_all_low
, which writes the string to the shared memory ring and notifies the console daemon in Dom0.
Each iteration is taking roughly 200 ticks, which is about 8 us per iteration.
So we'd expect the speed to be around 6 bytes / 8 us, which is about 700 KB/s.
Looking at the cumulative time spent in each function, the top entries are:
Function | Ticks (at 24 MHz) |
---|---|
caml_c_call | 9002738 |
caml_block_domain | 9001576 |
block_domain | 9001462 |
camlUnikernel__loop_1270 | 374418 |
camlConsole__write_all_low_1123 | 298735 |
Note: the trace only includes calls until the trace buffer was full, so these aren't the total times for the whole run.
But we can immediately see that we spent most of the time in block_domain
, which is what Mirage calls when it has nothing to do and is waiting for an external event.
Here's a graph showing how many iterations of the test loop we had started over time:
So, we wrote 679 messages very quickly, then waited a long time, then wrote 1027 more, then waited again, etc.
I thought there might be a bug in block_domain
causing it to miss a wake-up event, so I limited the time it would spend blocking.
It didn't make any difference; it would keep waking up, seeing that it had nothing to do, and going back to sleep again.
In case the problem was with Mirage's implementation of the shared rings or console device,
I tried writing the same test directly in C in Mini-OS's test.c
and got the same result (I had to modify it slightly because by default Mini-OS's console_print
discards data when the buffer is full instead of waiting).
Finally, I tried it from a Linux guest and got 25 KB/s (interestingly, Linux uses 100% CPU while doing this).
The times were highly variable (each point on this plot is from writing the message 10,000 times and calculating the average):
After some investigation, it turned out that Xen was deliberately limiting the rate:
1 2 3 4 |
|
Mystery solved, although I don't know why the rates are so variable.
Mirage wasn't doing anything except running the test case and Linux was booted with init=/bin/bash
, so there was nothing else running there either.
Lessons:
- Just scrolling through the raw trace can be misleading. It appears that it keeps calling the loop function, but in fact almost all the time was spent in the two very rare
block_domain
calls. Graphing iterations over time can show these problems effectively. - Compare with the speed on Linux. Sometimes, Xen really is that slow and it's not our fault.
- Compile everything for profiling or the results aren't much use.
Profiling UDP
TCP involves ack packets, expanding windows and other complications, so I next looked at the simpler UDP protocol. Here, we can throw packets out continuously without worrying about the other end.
With a payload size of 1476 bytes (the maximum possible for UDP), I got 17 MB/s.
All packets were successfully received on my laptop.
My Linux guest got 13.4 MB/s with nc -u < /dev/zero
, so we're actually faster!
Here's a sample iteration from the trace:
Start | Function | Duration |
---|---|---|
243418 | - - > - camlUnikernel__loop_1287 | 938 |
243436 | - - > - - camlUdpv4__fun_1430 | 266 |
243439 | - - > - - > camlIpv4__allocate_frame_1369 | 190 |
243440 | - - > - - > - camlIo_page__get_1122 | 72 |
243632 | - - > - - > camlIpv4__fun_1509 | 69 |
243818 | - - > - - camlNetif__fun_2893 | 185 |
243852 | - - > - - > camlNetif__fun_2618 | 103 |
243895 | - - > - - > - camlLwt_ring__fun_1223 | 58 |
244007 | - - > - - camlNetif__fun_2931 | 132 |
244009 | - - > - - > camlNetif__xmit_1509 | 123 |
244028 | - - > - - > - camlNetif__fun_2618 | 76 |
244141 | - - > - - camlNetif__fun_2958 | 177 |
244144 | - - > - - > camlRing__push_requests_and_check_notify_1112 | 174 |
244150 | - - > - - > - camlRing__sring_push_requests_1070 | 158 |
244151 | - - > - - > - - caml_memory_barrier | 77 |
244231 | - - > - - > - - caml_memory_barrier | 77 |
Here's a graph of loop iterations (packets sent) over time (each blue dot is one packet sent):
The gaps indicate places where we were not sending packets.
The garbage collector shows up twice in the trace (both times in Ring.ack_responses
oddly).
However, we spend more time in block_domain
than doing GC, indicating that we're often waiting for Xen.
Looking at the trace just before it blocks, I see calls to Netif.wait_for_free_tx
, which seems reasonable.
Profiling TCP
The TCP header is larger than the UDP one, making it less efficient even in the best case, and TCP needs to process acks, keep track of window sizes, and handle retransmissions. Strange, then, that the Linux guest manages 39 MB/s over TCP compared with just 13.4 MB/s for UDP! (even stranger is that I got 47.2 MB/s for Linux when I tried it for last month's post; however I am using a different version of Linux in dom0 now)
I capture some packets sent by the Linux guest using tshark
running in Dom0.
Loading it into Wireshark on my laptop, I see that all the TCP checksums are wrong, so it
looks like Linux is using TCP checksum offloading.
According to Question about TCP checksum offload in Xen:
A domain has no way of knowing how any given packet is going to leave the host (or even if it is) so it can't know ahead of time whether to calculate any checksums: the skb's [socket buffers] are just marked with "checksum needed" as usual and either the egress NIC will do the job or dom0 will do it.
Getting this working on Mirage was a bit tricky. The TCP layer can avoid adding the checksum only if the network device says it's capable of doing it itself, and packets have to be flagged as needing the checksum. You can't just flag all packets because the Linux dom0 silently drops non-TCP/UDP packets with it set (e.g. ARP packets). I hacked something together and got a modest speed improvement.
Here's a graph for the TCP test, where each iteration of the loop is sending one TCP packet (segment):
Note: we send many warm up packets before starting the trace as TCP starts slowly (which looks pretty but isn't relevant here).
Zooming in, the picture is quite interesting (where it had gaps, I searched for a typical function that occurred in the gap and added a symbol for it):
It looks like we start by transmitting packets steadily, until the current window is full.
Then we start buffering the packets instead of sending them, which is very fast.
At some point the TCP system stops accepting more data, which causes the main loop to block, allowing us to process other events.
rx_poll response
indicates one iteration of the Netif.rx_poll
loop, which seems to be dealing with acks from Xen saying that our packets have been transmitted (and the memory can therefore be recycled).
After a while, the TCP ack packets arrive and we process them, which opens up the transmit window again.
Then we send out the buffered packets, before returning to the main loop.
So, in each cycle we spend about 60% of the time transmitting packets, a quarter dealing with acks from Xen and the rest handling TCP acks from the remote host. It might be possible to optimise things a bit here by reusing grant references, but I didn't investigate further.
Profiling disk access
My next test case reads a series of sectors sequentially from the disk and then writes them. Reading or writing one sector (4096 bytes) at a time was very slow (2.7 MB/s read, 0.7 MB/s write). Using larger buffers, so that we transfer more in each operation, helped but even at 64 sectors per op I only got 12.3 MB/s read / 5.12 MB/s write (the device is capable of 20 MB/s read and 10 MB/s write). Here's a trace where we read using 32-sector buffers (10.9 MB/s):
We spend a lot of time waiting for each block to arrive, although there are some curious ack messages, which we deal with quickly. What if we have two requests in flight at once? This gets us 18.27 MB/s:
Strangely, the two blocks arrive close together. Although it takes us longer to get the first one (I don't know why), we get them more quickly after that. Having three requests in flight doesn't help though (18.25 MB/s):
Looking at the block driver code, it batches requests into groups of 11. This probably explains why 32 sectors-per-read did well - it's very close to 33.
For writing, the number of requests in flight makes little difference, but writing 8 sectors in each request is by far the best (7 MB/s).
I don't understand why we're not getting the full speed of the card here, since we're spending most of the time blocking. However, we are pretty close (18r/7w out of a possible 20r/10w), which is good enough for today.
Update: Linux is slow too!
I originally tested with hdparm
, which reports about 20 MB/s as expected:
$ hdparm -t /dev/mmcblk0
Timing buffered disk reads: 62 MB in 3.07 seconds = 20.21 MB/sec
But testing with dd
, I don't get this speed.
dd
's speed seems to depend a lot on the block size. Using 4096 * 11
bytes (which I assume is what dom0 would do in response to a single guest request), I get just 16.9 MB/s:
$ dd iflag=direct if=/dev/vg0/bench of=/dev/null bs=45056 count=1000
1000+0 records in
1000+0 records out
45056000 bytes (45 MB) copied, 2.65911 s, 16.9 MB/s
Block size (pages) | Linux dom0 | Linux domU |
---|---|---|
11 | 17.0 MB/s | 14.5 MB/s |
16 | 18.8 MB/s | 16.3 MB/s |
32 | 20.8 MB/s | 18.6 MB/s |
So perhaps Mirage is doing pretty well already - it's about as fast as the Linux guest. Xen seems to be the limiting factor here, because it doesn't allow us to make large enough requests.
Profiling the queuing service
Finally, I looked at applying all this new information to my queuing service.
As a baseline, wget
reports that I can currently download from it at 4.6 MB/s, with profiling compiled in but disabled:
There's some complicated copying going on because we're using the HTTP Chunked encoding, which writes the size of each chunk of data followed by the data itself, then the next chunk, etc. Since we know the length at the start, we can use the simpler Fixed encoding. This increases the speed to 5.2 MB/s. It's a shame the HTTP API uses strings everywhere: we have to copy the data from the disk buffer to a string on the heap to give it to the HTTP API, which then copies it back into a new buffer to send it to the network card. If it took a stream of buffers, we could just pass them straight through.
Finally, I added the read-ahead support from the block profiling above, which increased the speed to 6.8 MB/s. Here's the new graph, showing that we're sending packets much faster (note the change in the Y-scale):
I used a queue length of 5, with 33 sectors per request. I tried increasing it to 10, but that caused more GC work.
Conclusions
Even the unoptimised service is faster than my current (ADSL) Internet connection, so optimising it isn't currently necessary, but it's interesting to look at performance and get a feel for where the bottlenecks are.
Mirage doesn't have any specific profiling support, but the fact that the whole OS is a single executable makes profiling it quite easy.
OCaml's profile
option isn't a perfect fit for tracing because it doesn't record when a function finishes, but you can still get useful results from it.
Graphing some metric (e.g. packets sent) over time seemed the most useful way to look at the data.
I'm currently just using libreoffice's chart tool, but I should probably find something more suitable.
It would be great to be able to zoom in easily, show durations (not just events), filter the trace display easily, etc.
I'd also like support for following Lwt threads even when they block.
Recommendations for good visualisation tools welcome!
Writing to the Xen console from Mirage is slow because xenconsoled
rate limits us. Mirage still gets better performance than Linux though, and uses far less CPU (looks like Linux is just spinning). My UDP test kernel sent data faster than Linux's nc
utility (probably because nc
made a poor choice of payload size). Linux does very well on TCP. I don't know why it's so fast. Using Xen's TCP checksum offloading does help a bit though. SD card performance on Mirage is close to what the hardware supports when I choose the right request size and keep two requests in flight at once. It's surprising we don't manage the full speed, though. For networking and disk access, managing Xen's grant refs for the shared memory pages seems to take up a lot of time - maybe there are ways to optimise that.
With a few modifications (TCP checksum offload, HTTP fixed encoding, keeping multiple disk reads in flight and using optimal buffer sizes), I increased the download speed of my test service running on my ARM dev board from 2.46 MB/s to 7.24 MB/s (when compiled without profiling). I'm sure people more familiar with Mirage will have more suggestions.