This handout is for the companion class to the one whose handout formed my last post. While that class was user-focused, this one, “CP322-2 - Integrate F# into Your C# or VB.NET Application for an 8x Performance Boost”, is more developer-focused and takes the hood off the implementation of the BrowsePhotosynth application. The code for this special version of the application – which imports synchronously via C# and synchronously/asynchronously via F# – is available here for download.
Introduction
This class takes a look at the implementation of BrowsePhotosynth for AutoCAD, the ADN Plugin of the Month from October 2010 and the application showcased in the companion, user-oriented class, “AC427-4 - Point Clouds on a Shoestring”. We’ll look at some of the design decisions behind the application, particularly with respect to the use of F# to help download and process the various files making up a Photosynth’s point cloud.
To understand more about the purpose of this application, it’s first worth taking a look at the handout to the companion class. In a nutshell, the BrowsePhotosynth application allows users of AutoCAD 2011 (and, in due course, above) to browse the contents of the Photosynth web-service and easily bring down point clouds corresponding to the “synths” hosted on the site.
The System Architecture
Let’s take a look at the overall architecture of the system before diving into the individual components.
Here we can see a few DLLs are hosted by AutoCAD: the primary one is called ADNPlugin-BrowsePhotosynth.dll and implements a number of commands, the most important of which are BROWSEPS and IMPORTPS. It is this DLL that needs to be NETLOADed or demand-loaded into AutoCAD for the BrowsePhotosynth application to work properly.
Users will call BROWSEPS, which causes the UI component - ADNPlugin-PhotosynthBrowser.exe – to be launched to present a dialog user to the user. This dialog will – in turn – call the IMPORTPS back in AutoCAD to download, process and import the point cloud data. It’s during this processing phase that the main Importer may choose to use a separate component, the Processor DLL (ADNPlugin-PhotosynthProcessor.dll). This functionality has been packaged as a separate component as it was written in F#.
The User Interface
As mentioned above, the main entry-point into the application is the BROWSEPS command, which launches a WPF dialog implemented in the “Browser” project of the main solution.
This project actually builds an EXE (ADNPlugin-PhotosynthBrowser.exe), rather than a DLL, which is interesting for a few reasons:
- Isolation from AutoCAD
- This 32-bit EXE can be executed via WoW64 on 64-bit systems (more later on why this is important)
- The memory footprint is managed separately – it doesn’t contribute to the size of AutoCAD’s process space
- Portability to other products
- As we support point clouds across more of our products, having an independent GUI component will simplify migration to support them
- Can even by used standalone
- As the browser does not depend on AutoCAD, it can also be executed separately, allowing the user to build up the lost of point clouds to import before even launching AutoCAD
The overriding reason for this design was the first: the ability to run as 32-bit even on 64-bit OSs was important as we make use of a 3rd party component, csExWb2, which is currently only available as a 32-bit version. This component also causes an error when the hosting dialog closes (which you will see if you launch the EXE separately, as described in the third point above). It’s also for this reason – to isolate the user from this error, which cause AutoCAD stability problems when it happened in an in-process component – that the dialog is hosted in a separate application. The main plugin launches and maintains an instance of the dialog’s process: when the dialog is closed it is effectively hidden and the process gets terminated at an appropriate point later on, thus avoiding the error. If AutoCAD gets terminated unexpectedly (such as via the debugger), there may be a stray process which is detected and terminated (should the user request it) when the application is next used.
So why used a 3rd party component, if it causes all this problems? The component implements a web-browser control that reports the HTTP traffic generated by its contents to the application. This allows us to detect when the user has visited a page containing a synth, as the embedded Silverlight control requests a file named “points_0_0.bin”, the file containing the point cloud’s first 5,000 points. There may be other browser controls available that implement this capability, but I unfortunately haven’t found any.
As the application detects point clouds being accessed, they get added to the list on the right of the dialog using an image that is also pulled down from the Photosynth server.
The original UI was implemented using WinForms, but the benefits of using WPF quickly became apparent as additional UI features – such as the ability to resize the items using a slider – became desirable. It’s worth taking a look at some of the techniques used in the WPF application – here are some of them:
- Our list of point clouds is bound to an ObservableCollection<> of objects containing the information we care about
- We had to implement INotifyPropertyChanged for this to work properly
- We add items to this list when our HTTP event is fired, but as we’re not executing on the UI thread, at that point, we need to request the list to be updated via Dispatcher.Invoke()
- We use a WindowInteropHelper to make AutoCAD’s main window the parent of the EXE
- This gives a modal – rather than modeless – feel to the UI
- The display of our listbox has been customized significantly
- It selects on hover
- It has a custom gradient fill to better fit the look & feel of the Photosynth site
For additional information regarding the development of the WPF UI for this application, see this blog post.
Now on to how the UI communicates with AutoCAD. To avoid any dependency on AutoCAD from the WPF application, it simply uses Windows messages to launch the IMPORTPS command. This decoupling is healthy for portability reasons but also to make sure the command gets launched cleanly: it is considered best practice to launch commands in AutoCAD from a modeless UI, whether using AutoCAD’s SendStringToExecute() API or using Win32’s SendMessage().
The Import Process
The IMPORTPS command – which is actually the real heart of the application – has the following process:
It’s worth noting that the application doesn’t actually make much use of AutoCAD’s APIs: it uses SendStringToExecute() to fire off standard commands to index and attach the point cloud, but aside from that the plugin’s code is also fairly standalone in nature.
We’re most interested with the left-hand part of this process, where we bring the .bin files down from the Photosynth server and process them into a single text file. Let’s start by understanding why there are all these files to download, process and combine.
Photosynth’s web service stores point clouds in chunks of 5,000 points. So if we have a point cloud comprising 26,000 points it will be stored in 6 files named points_0_0.bin, points_0_1.bin, points_0_2.bin, points_0_3.bin, points_0_4.bin and points_0_5.bin, each containing 5,000 points, apart from the last which will only contain 1,000. This has most probably been done to enable the Silverlight control to stream down sections of the point cloud selectively/progressively. There may be additional point clouds (contained in files such as points_2_0.bin, etc.), but as these do not share a coordinate system with the primary point cloud, including them only proves confusing. These could easily be downloaded and combined into separate PCG files inside AutoCAD, but this has been left as an exercise for the user, as the merit of doing so seems somewhat dubious.
Now onto the main purpose of this class. :-)
Given the unordered nature of point clouds – at least from AutoCAD’s perspective – this presents us with a really interesting optimization opportunity: rather than downloading and processing the files sequentially via synchronous API calls, we can choose to perform this work asynchronously. Asynchronous programming is a hot topic, right now, and currently a key benefit F# brings over VB.NET and C# – hence the somewhat provocative title for this class. That said, Anders Hejlsberg’s recent announcement at PDC 2010 regarding the addition of asynchronous programming support to VB.NET and C# – which can be used right now with the Visual Studio Async CTP – will definitely enable this kind of functionality from your preferred .NET language, over time. Also worth checking out is the accompanying Channel 9 interview.
Anders’ demo shows very eloquently why the current mode of making asynchronous calls from VB.NET and C# is inadequate. While the Parallel Extensions for .NET – provided in .NET 4.0 – was a great addition for simplifying the management of multiple tasks, strong support for asynchronous calls was missing. This area is still a key advantage of using F#: its elegant Asynchronous Workflows feature makes the description and execution of asynchronous tasks really easy.
Before we look at applying Asynchronous Workflows to this problem, let’s see a standard synchronous approach in C#:
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Runtime;
using System.Net;
using System.IO;
using System;
namespace PhotosynthProcSyncCs
{
public class PointCloudProcessor
{
Editor _ed;
ProgressMeter _pm;
string _localPath;
public PointCloudProcessor(
Editor ed, ProgressMeter pm, string localPath
)
{
_ed = ed;
_pm = pm;
_localPath = localPath;
}
public long ProcessPointCloud(
string path, int[] dims, string txtPath
)
{
// Counter for the total number of points
long totalPoints = 0;
// Create our intermediate text file in the temp folder
FileInfo t = new FileInfo(txtPath);
StreamWriter sw = t.CreateText();
using (sw)
{
// We'll use a web client to download each .bin file
WebClient wc = new WebClient();
using (wc)
{
for (int maj=0; maj < dims.Length; maj++)
{
for (int min=0; min < dims[maj]; min++)
{
// Loop for each .bin file
string root =
maj.ToString() + "_" + min.ToString() + ".bin";
string src = path + root;
string loc = _localPath + root;
try
{
wc.DownloadFile(src, loc);
}
catch
{
return 0;
}
if (File.Exists(loc))
{
// Open our binary file for reading
BinaryReader br =
new BinaryReader(
File.Open(loc, FileMode.Open)
);
using (br)
{
try
{
// First information is the file version
// (for now we support version 1.0 only)
ushort majVer = ReadBigEndianShort(br);
ushort minVer = ReadBigEndianShort(br);
if (majVer != 1 || minVer != 0)
{
_ed.WriteMessage(
"\nCannot read a Photosynth point cloud " +
"of this version ({0}.{1}).",
majVer, minVer
);
return 0;
}
// Clear some header bytes we don't care about
int n = ReadCompressedInt(br);
for (int i = 0; i < n; i++)
{
int m = ReadCompressedInt(br);
for (int j = 0; j < m; j++)
{
ReadCompressedInt(br);
ReadCompressedInt(br);
}
}
// Find out the number of points in the file
int numPoints = ReadCompressedInt(br);
totalPoints += numPoints;
_ed.WriteMessage(
"\nProcessed points_{0} containing {1} points.",
root, numPoints
);
for (int k = 0; k < numPoints; k++)
{
// Read our coordinates
float x = ReadBigEndianFloat(br);
float y = ReadBigEndianFloat(br);
float z = ReadBigEndianFloat(br);
// Read and extract our RGB values
UInt16 rgb = ReadBigEndianShort(br);
int r = (rgb >> 11) * 255 / 31;
int g = ((rgb >> 5) & 63) * 255 / 63;
int b = (rgb & 31) * 255 / 31;
// Write the point with its color to file
sw.WriteLine(
"{0},{1},{2},{3},{4},{5}", x, y, z, r, g, b
);
}
}
catch (System.Exception ex)
{
_ed.WriteMessage(
"\nError processing point cloud file " +
"\"points_{0}\": {1}",
root, ex.Message
);
}
}
// Delete our local .bin file
File.Delete(loc);
// Show some progress
_pm.MeterProgress();
System.Windows.Forms.Application.DoEvents();
}
}
}
}
}
return totalPoints;
}
private static int ReadCompressedInt(BinaryReader br)
{
int i = 0;
byte b;
do
{
b = br.ReadByte();
i = (i << 7) | (b & 127);
}
while (b < 128);
return i;
}
private static float ReadBigEndianFloat(BinaryReader br)
{
byte[] b = br.ReadBytes(4);
return BitConverter.ToSingle(
new byte[] { b[3], b[2], b[1], b[0] },
0
);
}
private static UInt16 ReadBigEndianShort(BinaryReader br)
{
byte b1 = br.ReadByte();
byte b2 = br.ReadByte();
return (ushort)(b2 | (b1 << 8));
}
}
}
The important thing to note about the signature of the ProcessPointCloud() function – which I’ve kept the same across the C# and F# implementations – is the way the dims variable works: this is a simple array – populated by querying the Photosynth web service – of the number of files to download for the various point clouds in the synth. For example: if a synth contains three point clouds, the first comprising 5 files, the second of 3 files and the third of 1 file, { 5, 3, 1 } would get passed into the function, which would then attempt to download the following files: points_0_0.bin, points_0_1.bin, points_0_2.bin, points_0_3.bin, points_0_4.bin, points_1_0.bin, points_1_1.bin, points_1_2.bin and points_2_0.bin. As mentioned earlier, I’ve recently change the approach to only download the first point cloud for each synth, so only the points_0_x.bin files will be downloaded and processed. Which means dims will now always only have one entry.
The above code works in a very linear fashion: download a file, process it, download the next, process that, etc.
Now let’s take a look at the equivalent Asynchronous Workflows implementation in F#:
module PhotosynthProcAsyncFs
open System.Globalization
open System.Threading
open System.Text
open System.Net
open System.IO
open System
// We need the SynchronizationContext of the UI thread,
// to allow us to make sure our UI update events get
// processed correctly in the calling application
let mutable syncContext : SynchronizationContext = null
// Asynchronous Worker courtesy of Don Syme:
// http://blogs.msdn.com/dsyme/archive/2010/01/10/async-and-
// parallel-design-patterns-in-f-reporting-progress-with-
// events-plus-twitter-sample.aspx
type Agent<'T> = MailboxProcessor<'T>
type SynchronizationContext with
// A standard helper extension method to raise an event on
// the GUI thread
member syncContext.RaiseEvent (event: Event<_>) args =
syncContext.Post((fun _ -> event.Trigger args),state=null)
type AsyncWorker<'T>(jobs: seq<Async<'T>>) =
// Each of these lines declares an F# event that we can raise
let allCompleted = new Event<'T[]>()
let error = new Event<System.Exception>()
let canceled = new Event<System.OperationCanceledException>()
let jobCompleted = new Event<int * 'T>()
let cancellationCapability = new CancellationTokenSource()
// Start an instance of the work
member x.Start() =
// Capture the synchronization context to allow us to raise
// events back on the GUI thread
if syncContext = null then
syncContext <- SynchronizationContext.Current
if syncContext = null then
raise(
System.NullReferenceException(
"Synchronization context is null."))
// Mark up the jobs with numbers
let jobs = jobs |> Seq.mapi (fun i job -> (job,i+1))
let work =
Async.Parallel
[ for (job,jobNumber) in jobs ->
async { let! result = job
syncContext.RaiseEvent
jobCompleted (jobNumber,result)
return result } ]
Async.StartWithContinuations(
work,
(fun res -> syncContext.RaiseEvent allCompleted res),
(fun exn -> syncContext.RaiseEvent error exn),
(fun exn -> syncContext.RaiseEvent canceled exn ),
cancellationCapability.Token)
member x.CancelAsync() =
cancellationCapability.Cancel()
// Raised when a particular job completes
member x.JobCompleted = jobCompleted.Publish
// Raised when all jobs complete
member x.AllCompleted = allCompleted.Publish
// Raised when the composition is cancelled successfully
member x.Canceled = canceled.Publish
// Raised when the composition exhibits an error
member x.Error = error.Publish
type PointCloudProcessor() =
// Mutable state to track progress and results
let mutable jobsComplete = 0
let mutable jobsFailed = 0
let mutable totalJobs = 0
let mutable totalPoints = 0
let mutable completed = false
// Event to allow caller to update the UI
let jobCompleted = new Event<string * int>()
// Function to access a stream asynchronously
let httpAsync(url:string) =
async {
let req = WebRequest.Create(url)
let! rsp = req.AsyncGetResponse()
return rsp.GetResponseStream()
}
// Functions to read data from our point stream
let rec readCompressedInt (i:int) (br:BinaryReader) =
let b = br.ReadByte()
let i = (i <<< 7) ||| ((int)b &&& 127)
if (int)b < 128 then
readCompressedInt i br
else
i
let readBigEndianFloat (br:BinaryReader) =
let b = br.ReadBytes(4)
BitConverter.ToSingle( [| b.[3]; b.[2]; b.[1]; b.[0] |], 0)
let readBigEndianShort (br:BinaryReader) =
let b1 = br.ReadByte()
let b2 = br.ReadByte()
((uint16)b2 ||| ((uint16)b1 <<< 8))
// Recursive function to read n points from our stream
// (We use an accumulator variable to enable tail-call
// optimization)
let rec readPoints acc n br =
if n <= 0 then
acc
else
// Read our coordinates
let x = readBigEndianFloat br
let y = readBigEndianFloat br
let z = readBigEndianFloat br
// Read and extract our RGB values
let rgb = readBigEndianShort br
let r = (rgb >>> 11) * 255us / 31us
let g = ((rgb >>> 5) &&& 63us) * 255us / 63us
let b = (rgb &&& 31us) * 255us / 31us
readPoints ((x,y,z,r,g,b) :: acc) (n-1) br
// Function to extract the various point information
// from a stream corresponding to a single point file
let extractPoints br =
// First information is the file version
// (for now we support version 1.0 only)
let majVer = readBigEndianShort br
let minVer = readBigEndianShort br
if (int)majVer <> 1 || (int)minVer <> 0 then
[]
else
// Clear some header bytes we don't care about
let n = readCompressedInt 0 br
for i in 0..(int)n-1 do
let m = readCompressedInt 0 br
for j in 0..(int)m-1 do
readCompressedInt 0 br |> ignore
readCompressedInt 0 br |> ignore
// Find out the number of points in the file
let npts = readCompressedInt 0 br
// Read and return the points
readPoints [] npts br
// Recursive function to create a string from a list
// of points. Our accumulator is a StringBuilder,
// which is the most efficient way to collate a
// string
let rec pointsToString (acc : StringBuilder) pts =
match pts with
| [] -> acc.ToString()
| (x:float32,y:float32,z:float32,r,g,b) :: t ->
acc.AppendFormat(
"{0},{1},{2},{3},{4},{5}\n",
x.ToString(CultureInfo.InvariantCulture),
y.ToString(CultureInfo.InvariantCulture),
z.ToString(CultureInfo.InvariantCulture),
r,g,b)
|> ignore
pointsToString acc t
// Expose an event that's subscribable from C#/VB
[<CLIEvent>]
member x.JobCompleted = jobCompleted.Publish
// Property to indicate that we're done
member x.IsComplete = completed
// Property to find out of any fyailures
member x.Failures = jobsFailed
// Property to return the results
member x.TotalPoints = totalPoints
// Our main function to download and process the point
// cloud(s) associated with a particular Photosynth
member x.ProcessPointCloud baseUrl dims txtPath =
// A local function to add the URL prefix to each file
let pathToFile file = baseUrl + file
// Generate our list of files from the list of dimensions
// of the various point clouds
// Each entry in dims corresponds to the number of files:
// dims[0] = 5 means "points_0_0.bin" .. "points_0_4.bin"
// dims[6] = 3 means "points_6_0.bin" .. "points_6_2.bin"
let files =
Array.mapi
(fun i d ->
Array.map (fun j -> sprintf "%d_%d.bin" i j) [| 0..d-1 |]
)
dims
|> Array.concat
|> List.ofArray
// Set/reset mutable state
totalJobs <- files.Length
jobsComplete <- 0
// Open the local, temporary text file to hold our points
let t = new FileInfo(txtPath)
let sw = t.Create()
// An agent to store our points in the file...
// Loops and receives messages, so that we ensure we don't
// have a conflict of simultaneous writes
let fileAgent =
Agent.Start(fun inbox ->
async { while true do
let! (msg : string) = inbox.Receive()
do! sw.AsyncWrite(Encoding.ASCII.GetBytes(msg)) })
// Our basic asynchronous task to process a file, returning
// the number of points
let processFile (file:string) =
async {
let! stream = httpAsync file
use reader = new BinaryReader(stream)
let pts = extractPoints reader
pointsToString (new StringBuilder()) pts |> fileAgent.Post
return file, pts.Length
}
// Our jobs are a set of tasks, one for each file
let jobs =
[for file in files -> pathToFile file |> processFile]
// Create our AsyncWorker for our jobs
let worker = new AsyncWorker<_>(jobs)
// Raise an event when each file is processed and update
// our internal state
worker.JobCompleted.Add(fun (jobNumber, (url , ptnum)) ->
let file = url.Substring(url.LastIndexOf('/')+1)
jobsComplete <- jobsComplete + 1
syncContext.RaiseEvent jobCompleted (file, ptnum)
// If the last job, close our temporary file
if jobsComplete = totalJobs then
sw.Close()
sw.Dispose()
)
// Raise an event when an error occurs
worker.Error.Add(fun ex ->
jobsComplete <- jobsComplete + 1
jobsFailed <- jobsFailed + 1
syncContext.RaiseEvent jobCompleted ("Failed", 0)
)
// Raise an event on cancellation
worker.Canceled.Add(fun ex ->
worker.CancelAsync()
jobsComplete <- totalJobs
jobsFailed <- totalJobs
)
// Once we're all done, set the results as state to be
// accessed by our calling routine
worker.AllCompleted.Add(fun results ->
totalPoints <- Array.sumBy snd results
completed <- true)
// Now start the work
worker.Start()
This implementation makes use of the AsyncWorker<> class: a standard design pattern implemented by Don Syme to report progress during a series of asynchronous tasks. We have events – raised on the UI thread, which means we can call back into AutoCAD safely – for when tasks complete, fail or get cancelled. Let’s take a closer look at the core function in this implementation, that which processes a single file:
1 let processFile (file:string) =
2 async {
3 let! stream = httpAsync file
4 use reader = new BinaryReader(stream)
5 let pts = extractPoints reader
6 pointsToString (new StringBuilder()) pts |> fileAgent.Post
7 return file, pts.Length
8 }
The first line names the function and declares it to take a string argument. The second line says the whole operation is to be considered an asynchronous task, which means it can be executed in a non-blocking way. The third line is the only actual call that is made asynchronously, as denoted by the “!”, which tells the F# compiler to call the function but only continue with the assignment and the code following it once the results arrive. The rest of the code actually just takes the downloaded file and processes its contents by using a BinaryReader and extracting the various points in a very linear fashion. There was really no advantage to be had in attempting to make the processing asynchronous – the real benefit is derived from performing the download asynchronously rather than the processing.
One important point about the way this code works: it’s all very well requesting data which then gets processed in a random order as it arrives back, but we do need to coordinate placing that data into our text file (which, as you saw in the process diagram, will then get processed into a LAS file before that, in turn, gets indexed into a PCG and attached inside an AutoCAD drawing). The points can come in any order – we’re not fussy about the ordering – but they do need to all make it in there. The way I’ve approached that is to use a agent-based message-passing (as described in more detail in this blog post). This sets up a central mailbox for requests to write to out text file: as the messages get processed, the points get added to the file.
What’s important to note about these “tasks”: we have spent all out time saying what needs to happen – not when. The timing of the execution of these tasks is handled completely by the F# runtime and the .NET Framework – we really don’t need to care about how and when things happen.
Just to make sure there wasn’t any inherent benefit from using F# rather than C#, I also implemented an additional “F# synchronous” mode. The code is provided in the project associated with this class.
What’s potentially of more interest is the approach I’ve used to switch between the various implementations: I’ve used a capability in AutoCAD 2011 allowing system variables to be added via the Registry. Here’s the .reg file I’ve used to do this:
Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SOFTWARE\Autodesk\AutoCAD\R18.1\ACAD-9001:409\Variables\BROWSEPSSYNC]
"StorageType"=dword:00000002
"LowerBound"=dword:00000000
"UpperBound"=dword:00000002
"PrimaryType"=dword:0000138b
@="0"
[HKEY_LOCAL_MACHINE\SOFTWARE\Autodesk\AutoCAD\R18.1\ACAD-9001:409\Variables\BROWSEPSLOG]
"StorageType"=dword:00000002
"LowerBound"=dword:00000000
"UpperBound"=dword:00000001
"PrimaryType"=dword:0000138b
@="0"
This adds two sysvars to AutoCAD:
- BROWSEPSSYNC – an integer between 0 and 2, stored per-user
- Used to indicate the synchrony mode:
- 0 = C# synchronous
- 1 = F# synchronous
- 2 = F# asynchronous
- BROWSEPSLOG – an integer between 0 and 1, stored per-user
- Used to indicate whether to save the performance information in a log file
We’re then able to get the values of these system variables using (for instance) Application.GetSystemVariable(“BROWSEPSSYNC”) in our code. The IMPORTPS implementation now uses the appropriate user-selected synchrony mode, and – optionally – stores the performance data in “Documents\Photosynth Point Clouds\log.txt”.
To put the code through its paces, I modified the mode before importing each of a number of my favourite synths, in increasing order of size:
Vietnam Memorial Statue using C# synchronous: 46293 points from 10 files in 00:00:06.7260000
Vietnam Memorial Statue using F# synchronous: 46293 points from 10 files in 00:00:02.3120000
Vietnam Memorial Statue using F# asynchronous: 46293 points from 10 files in 00:00:01.5560000
National Geographic - Sphinx using C# synchronous: 102219 points from 21 files in 00:00:17.3470000
National Geographic - Sphinx using F# synchronous: 102219 points from 21 files in 00:00:16.5290000
National Geographic - Sphinx using F# asynchronous: 102219 points from 21 files in 00:00:04.2920000
L'epee de la Tene using C# synchronous: 405998 points from 82 files in 00:00:57.8160000
L'epee de la Tene using F# synchronous: 405998 points from 82 files in 00:00:54.2560000
L'epee de la Tene using F# asynchronous: 405998 points from 82 files in 00:00:08.3740000
Another Tres Yonis Synth using C# synchronous: 1141257 points from 229 files in 00:03:56.0490000
Another Tres Yonis Synth using F# synchronous: 1141257 points from 229 files in 00:04:20.4900000
Another Tres Yonis Synth using F# asynchronous: 1141257 points from 229 files in 00:00:21.3790000
Just to be clear: I did change the order of the execution – to make sure “C# synchronous” didn’t have the disadvantage of pulling down data that was cached for the other modes – but I’ve reordered them for ease of reading (it didn’t change anything at all in terms of results).
Here’s the data in a graphical form:
We can see that the difference in performance between C# and F# when working synchronously is modest: F# seems a bit quicker overall by then C# was much a bit quicker on the largest. But both were blown away by F# asynchronous: at worst F# async was 4 times faster, but at best it was 12 times faster!