Some observations on saving to file
This post from Dare Obasanjo has led me to some very interesting reading. Broadly, the question is what we should optimize when thinking about disk access.
Traditional thinking would say that we want to write as little as possible, because the disk is slow. But it turn out that this is not quite accurate in the way I used to think about it. The problem is not that the disk is slow, but that seek time is slow. Let us consider the following problem:
Given a file with N integers in it, with values from 0..N, update every 100th number to twice its current value.
On the face of it, this looks like a good way of solving the problem:
using file = File.OpenWrite("my.file"): for i in range(N): file.Position = i * 4 // position of number in file bytes = BitConverter.GetBytes(i*2) file.Write(bytes, 0, 4) file.Flush()
It turns out that it is actually significantly cheaper to overwrite the old values rather than actually skip ahead. And this is in a situation where we always skip ahead.
I decided that I wanted to test three scenarios:
- Sequential sparse update - updating a record every N bytes, basically the code we have above. On the face of it, this looks like it would be the most efficient approach, since we make the least amount of modifications, and we always seek in one direction.
- Sequential update - overwrite the entire file.
- Random update - update each record in isolation, but do not do this in a sequential order.
Here are the actual numbers, for a file where N is one million (file size is 3.8 MB). The numbers on the Y axis represent the duration in milliseconds.
Note that in order to actually see this in action, you need to disable the OS buffering, otherwise the OS will optimize the writes for you to be as sequential as possible. That said, even when using the OS buffers, there is still an interesting affect going on. As you can see in this graph:
Note, however, that without write through, the data is not actually written to disk, the OS is using delayed write, so a system crash can cause data loss. (In the vast majority of cases, you should not use write through, mind you, you should let the OS handle this).
Those numbers are relatively consistent no matter what is the file size I am talking about. For a file of 38 MB in size, it takes 8 seconds to overwrite it fully, 64 seconds to perform sparse update and over five minutes to perform a random update.
And here is the code that I used to test this:
var useWriteThrough = FileOptions.WriteThrough; using (var log = File.CreateText("log.txt")) { var baseCounts = new[] { 10000, 100000, 1000000, }; var path = "test.data"; log.WriteLine("Bytes\tInitial write\tSequential Sparse Update Write\tSequential Update Write\tRandom Update Write"); for (int j = 0; j < baseCounts.Length; j++) { var count = baseCounts[j]; var sizeInBytes = count*sizeof (int); Console.WriteLine("File size is: " + (sizeInBytes/1024m/1024m) + " MB"); log.Write(sizeInBytes + "\t"); if (File.Exists(path)) File.Delete(path); Stopwatch stopwatch = Stopwatch.StartNew(); using ( Stream stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 0x1000, useWriteThrough | FileOptions.SequentialScan)) { for (int i = 0; i < count; i++) { var bytes = BitConverter.GetBytes(i); stream.Write(bytes, 0, bytes.Length); } stream.Flush(); } stopwatch.Stop(); log.Write("{0}\t", stopwatch.ElapsedMilliseconds); stopwatch = Stopwatch.StartNew(); using ( Stream stream = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.None, 0x1000, useWriteThrough | FileOptions.SequentialScan)) { for (int i = 0; i < count; i += 100) { stream.Position = sizeof (int)*i; var bytes = BitConverter.GetBytes(i*2); stream.Write(bytes, 0, bytes.Length); } stream.Flush(); } stopwatch.Stop(); log.Write("{0}\t", stopwatch.ElapsedMilliseconds); stopwatch = Stopwatch.StartNew(); using ( Stream stream = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.None, 0x1000, useWriteThrough | FileOptions.SequentialScan)) { for (int i = 0; i < count; i++) { int value; if (i%100 == 0) value = i*2; else value = i; var bytes = BitConverter.GetBytes(value); stream.Write(bytes, 0, bytes.Length); } stream.Flush(); } stopwatch.Stop(); log.Write("{0}\t", stopwatch.ElapsedMilliseconds); stopwatch = Stopwatch.StartNew(); using ( Stream stream = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.None, 0x1000, useWriteThrough | FileOptions.RandomAccess)) { var rand = new Random(); for (int i = 0; i < count; i += 100) { // this ensure that we are always writing at int size, not half int stream.Position = rand.Next(sizeof (int), count); stream.Position = stream.Position - stream.Position%sizeof (int); var bytes = BitConverter.GetBytes(i*2); stream.Write(bytes, 0, bytes.Length); } stream.Flush(); } stopwatch.Stop(); log.WriteLine("{0}", stopwatch.ElapsedMilliseconds); log.Flush(); } }
I wonder if this means that I should treat making a disk call as a remote call, and use the same optimization techniques there as well.
Comments
"I wonder if this means that I should treat making a disk call as a remote call, and use the same optimization techniques there as well."
As long as you are focusing on what's really important for your application, I'd say most definitely.
If you want a different way to think about this problem, you can think of disk seek as latency and disk throughput as bandwidth. Everything else still applies.
It's also interesting to note that, in the recent history of computer architecture, bandwidth and latency have had a predictable relationship.
There's a rule of thumb that says the improvement in bandwidth will be at least the square of improvement in latency. That means a 10% improvement in latency will usually give you at least a 100% improvement in bandwidth in the next generation of hardware. That's held true for cpu, disk, network, and memory technology.
So, to put that into a format that's practical, over time, latency in your system will see incremental improvements. Bandwidth will see at least exponential improvements. Optimize and design accordingly.
The difference is even more apparent if you use a more reasonably sized buffer. 64K would work, although I think 1MB is more typical these days.
Something else to consider is ensuring that you write defragmented files when possible. The OS (Windows) will not do this for you, so it's your responsibility to call FileStream.SetLength if you have a good idea how big the file will get. (I've even found this to be beneficial on Linux where the OS does a better job about avoiding fragmentation.)
Also as SSD's become the norm, then we may want to avoid writing data smaller than N bytes so as to avoid wearing out the drive prematurely. (My understanding is that NAND Flash always writes in N byte blocks where N is typically 16K-128K, and that you have a limited number (1mil) of these writes before the drive fails.)
Here are a few more data points ...
Vista 32, 2.66Ghz Core 2, Samsung 7200rpm disk, writethrough, 1MB bufsize, defragged file
4000000 81 5583 83 4150
Same, but with WD Raptor 10Krpm disk
4000000 97 1683 99 1731
Same, but with thumb drive
4000000 395 10699 293 140377
Vista 32, 2.5 Ghz Core 2 laptop, Memoright SSD, writethrough, 1MB bufsize, defragged file
4000000 197 2458 117 2117
Is it just me or is anybody else worried about evan's "quared" example. ;-)
Justin,
Those are great points, thanks.
Tobi,
Can you explain?
Ayende, re Tobi: I think he means that 10% squared != 100% ;
MikeS: Absolutely right. It's 10% vs 21% improvement.
Btw. great video about latency issues http://video.google.com/videoplay?docid=-4714369049736584770&ei=pZmNSL-gOZ_q2gKNk53AAw
Comment preview