Preface


The One Billion Row Challenge reads input.txt, groups measurements by station, and prints the minimum, mean, and maximum temperature for each station in alphabetical order. The input format is deliberately narrow:


Station name;12.3

Station names are UTF-8, temperatures have one decimal digit, and the challenge permits up to 10,000 distinct stations.


I wrote Program.fs to see how far an F# implementation could be pushed "in a slow language." The file used in this investigation was 13,795,480,162 bytes and produced 413 station rows. It was warm in the Linux page cache. This is therefore a parsing and memory-throughput result, not a storage benchmark.


The test machine was a Ryzen 9 5900HS with eight physical cores, sixteen logical CPUs, 16 MiB of L3 cache, and 30 GiB of memory. Every executable mentioned below produced byte-for-byte identical output for this file.


Starting point: map bytes, do not create lines


Before writing a single line of code I knew that starting with the conventional approach would not get me very far, so I went directly to mmap and byte*, adding these zero-cost abstractions (I have checked the x64 assembly):


type nativeptr<'T when 'T : unmanaged> with
    member inline ptr.Item // Adding pointer[100] indexing.
        with get (index: int64) =
            NativePtr.read (NativePtr.ofNativeInt<'T> (NativePtr.toNativeInt ptr + nativeint (index * int64 sizeof<'T>)))
        and set (index: int64) value =
            NativePtr.write (NativePtr.ofNativeInt<'T> (NativePtr.toNativeInt ptr + nativeint (index * int64 sizeof<'T>))) value

    member inline ptr.WithOffset(offset: int64) = // Adding pointer arithmetic
        NativePtr.ofNativeInt<'T> (NativePtr.toNativeInt ptr + nativeint (offset * int64 sizeof<'T>))

Then, in Program.fs, we open the read-only memory-mapped file and acquire a nativeptr<byte> to the mapping. A station name is represented by ByteView: a pointer into the mapping plus its byte length, so we do not have to copy the names.


let f = File.OpenHandle(
    "./input.txt", mode = FileMode.Open, access = FileAccess.Read,
    options = FileOptions.SequentialScan)
let mmf = MemoryMappedFile.CreateFromFile(
    fileHandle = f, mapName = null, capacity = 0,
    access = MemoryMappedFileAccess.Read,
    inheritability = HandleInheritability.None, leaveOpen = true)
let view = mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read)
view.SafeMemoryMappedViewHandle.AcquirePointer(&ptr)

This removes line objects, station-name copies, UTF-8 decoding, and general number parsing from the measurement loop. The code still uses normal F# constructs around the mapping: structs, arrays, records, and local functions.


Profiling the first implementation


After writing the initial implementation, I profiled it and found that most of the time was spent parsing temperatures with Double.Parse. The 1BRC format does not need a general-purpose floating-point parser: temperatures have an optional minus sign, one or two integer digits, and exactly one fractional digit. That leaves just two decisions: whether the number is negative, and whether its integer part has one digit or two.


- 12.3


so using some likely undefined behavior in .NET I have written this marvel of engineering:


let inline boolToInt (value: bool) : int =
    int (Unsafe.BitCast<bool, byte>(value))

On previous versions of .NET you did not have the easy Unsafe module and you had to resort to pointers, but this one translates to 1 or even 0 x64 instructions depending on context, and zero branches.


And with the Boolean-to-integer conversion we can offset our way into skipping branches entirely at the cost of more code:


let negInt = boolToInt (ptr.[i] = (byte)'-')
let i = i + int64 negInt
let oneDigit = boolToInt (ptr.[i + 1L] = (byte)'.')
let firstDigit = int (ptr.[i] - (byte)'0')
let secondDigit = int (ptr.[i + 1L] - (byte)'0')
let fractionalDigit = int (ptr.[i + 3L - int64 oneDigit] - (byte)'0')
let integerPart = firstDigit * (10 - 9 * oneDigit) + secondDigit * (1 - oneDigit)
let value = (1 - 2 * negInt) * (integerPart * 10 + fractionalDigit)

There is no Double.Parse, culture lookup, allocation, or floating-point operation in this code path.


Finding the semicolon eight bytes at a time


The station-name scan is the second critical part of the challenge. A byte-at-a-time loop checks for ';' and updates the station identifier once per name byte. But instead, we read an unaligned uint64, XOR it with eight semicolons, and apply the standard zero-byte test:


(x - 0x0101010101010101) & ~x & 0x8080808080808080

An empty result means the word contains no semicolon. The program incorporates all eight bytes and advances by eight. A non-empty result identifies the matching byte with TrailingZeroCount. A byte-wise path remains for the final incomplete word.


The parser then reads the fixed temperature shape and returns everything the caller needs:


struct (nextPosition, nameLength, nameHash, measurement)

The fused result matters more than its compact syntax. The table lookup does not have to rediscover where the name ended, and the next iteration already knows where the next record begins.


Multithreading


The multithreaded version divides the mapping into sixteen contiguous ranges. Each proposed boundary moves forward to the next newline, so no worker starts in the middle of a record. Each worker owns a separate lookup table and separate arrays for station minima, maxima, totals, and counts.


There is no lock or shared dictionary on the measurement path. After all workers finish, the program merges at most the distinct station aggregates, sorts the final station views, and writes the output.


The first version submitted fifteen ranges through Task.Run while the main thread processed the remaining range. It produced the first complete 1.984-second run.


The later version uses fifteen explicit Thread workers and lets the main thread process the sixteenth range. It is a one-shot throughput program with a fixed number of independent jobs. Here the use of ThreadPool/Task adds more overhead and .NET machinery when the simpler primitive is faster and simpler to use.


[<EntryPoint>]
let main _args =
    // ...
    let results = Array.zeroCreate<WorkerResult> workerCount
    let workers =
        Array.init (workerCount - 1) (fun workerId ->
            let resultId = workerId + 1
            let struct (start, finish) = ranges[resultId]
            let worker = Thread(ThreadStart(fun () -> results[resultId] <- processRange ptr start finish size))
            worker.IsBackground <- true
            worker)
    for worker in workers do worker.Start()
    let struct (firstStart, firstFinish) = ranges[0]
    let firstResult = processRange ptr firstStart firstFinish size
    results[0] <- firstResult
    for workerId = 1 to workerCount - 1 do
        workers[workerId - 1].Join()
    // ...

The first C++ comparison


I then asked GPT-5.6 to write a self-contained, multithreaded C++ version. It was explicitly instructed not to inspect Program.fs, I replaced it with an empty file anyway. The restriction made the first comparison useful: it compared two independently chosen parser designs rather than one design in two syntaxes.


The C++ program was built with GCC 16 using -O3 -march=native -flto. It used mmap, direct threads, worker-local aggregation, integer temperature parsing, and a final sort. It was a serious implementation of the same overall architecture.


The original five-run warm rotation was:

Build Mean wall-clock time
F# JIT 1.962 s
F# Native AOT 1.997 s
GCC 16 C++ 2.008 s

The JIT build, not Native AOT, was the fastest F# build in that set. Here is an example on how JIT compilation is generally faster than ahead-of-time compilation because it allows the .NET machinery to use the native CPU instructions instead of the broader compatible x64 subsets, the CPU supports up to AVX-2, but the NativeAOT does not support AVX-2 by default, it can via some compilation configs I did not get into.


The difference between the F# JIT and GCC C++ was visible in both source and profiles. The first C++ parser, which GPT produced after multiple rounds of asking it to do better, scanned each station name one byte at a time. The F# parser tested eight bytes at a time. perf attributed almost all of each program’s samples to its range-processing function; GCC C++ spent 80.1% of sampled cycles in parse_range. The F# JIT retired 148.34 billion instructions in the measured run family, compared with 184.14 billion for GCC C++. It also executed 21.62 billion branches compared with 25.60 billion.


I also tried leaving the file mapped in the hopes that it would save a few ms: let f = File.OpenHandle() without use or f.Dispose(), it did not make any difference.


Moving F# from 1.9 seconds to 1.65 seconds


After the first comparison, the F# work continued in three directions.


Tiered compilation was disabled for the benchmark configuration. This process spends nearly all of its useful lifetime in one loop, so it should enter that loop with optimized JIT code instead of spending part of the run transitioning between JIT tiers.


The delimiter scan widened from a 64-bit word to a 16-byte SSE2 comparison. Sse2.CompareEqual tests a Vector128<byte> against sixteen semicolons, and Sse2.MoveMask returns the matching-byte mask. The existing 64-bit path remains for the final short record, where a 16-byte load would exceed the mapped range.


Finally, parseLine prefetches the next record after computing its start address, while the caller updates the current record’s table entry:


if nextPosition < size then
    Sse.Prefetch0(NativePtr.toVoidPtr (ptr.WithOffset nextPosition))

This was retained because it improved the measured result by a few ms. On this machine and input, the final direct-thread F# JIT averaged 1.650 seconds across five alternating runs.


The Clang rematch


At this point I gave GPT-5.6 access to the first F# implementation and asked it to optimize the C++ version as far as it could. I also asked it to try Clang.


Clang 22 built a smaller and faster executable than the earlier GCC version. The C++ parser adopted the same word-at-a-time delimiter scan and block-based station identifier update used by the F# parser. One suggested SIMD change did not survive benchmarking: a 16-byte C++ delimiter probe was slower than the 64-bit scan for this station-name distribution, so it was removed.


The final five-run alternating comparison was:

Build Individual runs Mean wall-clock time
Clang 22 C++ 1.463, 1.443, 1.463, 1.470, 1.486 s 1.465 s
F# JIT 1.657, 1.650, 1.626, 1.662, 1.653 s 1.650 s

Clang C++ was 11.2% faster in this final set. It won after the C++ program had access to the parser work that had previously been discovered independently in F#.


What the comparison shows


The point of this article is that F# and .NET is no longer the slow Java clone runtime that it was in the .NET Framework 2.0 days, it is now a complete high level and practical framework that includes the tools to make the hot loops fast, and the rest of the program simple.


F# won because it allows the use of better tactics and low level knowledge to achieve the goal. I understood that writing a line by line or byte by byte parser will not get me very far and will need to be rewritten eventually, so I started with the heavy artillery from the start: The custom branchless parser, the SIMD delimiter finder and the custom multithread dictionaries.


C++ did win because it is made for this, it inherits all the low level performance and syntax from C, AVX as first class features and the advantage of being everywhere so GPT can write the best version of it in almost one-shot.