MemoryMappedFile is up to ~1000x slower than Java for single-byte reads

Open
#114,846 2 comments 6 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
35/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Stale
Tech stack
csharp

Research direction

Start by reproducing the supplied C# benchmark using MemoryMappedFile.CreateViewAccessor and the single-byte ReadByte path, comparing single- and multi-threaded results. Trace the relevant MemoryMappedFile implementation and platform behavior, then verify that the reported single-byte performance gap is resolved without regressing multi-byte reads.

Written by the indexing model from the issue text.

Description

area-System.IO tenet-performance
Description

.NET's MemoryMappedFile can be up to the order of 1000x slower than equivalent Java code for multi-threaded single-byte reads. It is on the order of 10x slower for single-threaded single-byte reads, indicating a performance issue with single-byte reads that is exacerbated by concurrent reads. This performance gap goes away for multi-byte reads (i.e. 1kB at a time), where .NET can be faster.

It is common in some algorithms, like decompression for example, to need to read a single byte to know what to do with the next bytes in the file. We noticed on our project a severe performance degradation compared to Java when using memory-mapped files, and determined it was due to multi-threaded single-byte reads.

These performance results are on macOS ARM64, although the performance issue can be seen on all operating systems and architectures tested (Windows, Linux, and macOS; arm64 and x64 where applicable). This is the time to count the number of zeroes, one byte at a time, in a memory-mapped file of random data. I had seen O(1000)x slower results, although that was on an older ARM64 Mac that I no longer have access to. Still, the 518x slower is halfway to that order of magnitude.

Also note that I am not ruling out the possibility of doing something wrong here, although we are seeing this performance issue in real-world LZ4 decompression code that caused me to dig into this.

The related proposals #57330 and #37227 could likely resolve this performance issue, but I wanted to report this in the form of a reproducible performance bug instead of an API proposal.

Configuration

macOS 15.4, ARM64, M4 Max
.NET 9
Java OpenJDK 23

Regression?

No

Data
Framework Bytes at a time Threads Results
Java 1 8 0.030s
Java 1 1 0.064s
Java 1024 8 0.085s
Java 1024 1 0.139s
.NET 1 8 15.54s (518x slower)
.NET 1 1 0.908s (14x slower)
.NET 1024 8 0.035s (2.4x faster)
.NET 1024 1 0.084s (1.7x faster)
Analysis

To reproduce these results, create a 100MB file of random data: dd if=/dev/urandom of=random_file.dat bs=1m count=100

.NET test code:

using System.Buffers;
using System.Diagnostics;
using System.IO.MemoryMappedFiles;

if (args.Length == 0)
{
    Console.WriteLine("Usage: MMapTest.exe <file> [readCount=1] [threads=8]");
    return;
}

var file = args[0];
var fileInfo = new FileInfo(file);
if (!fileInfo.Exists)
{
    Console.WriteLine($"File not found: {file}");
    return;
}

int readCount = args.Length > 1 ? int.Parse(args[1]) : 1;
int threads = args.Length > 2 ? int.Parse(args[2]) : 8;

using var fs = fileInfo.OpenRead();
using var mmf = MemoryMappedFile.CreateFromFile(fs, null, fs.Length, MemoryMappedFileAccess.Read, HandleInheritability.Inheritable, false);

using var accessor = mmf.CreateViewAccessor(0, fs.Length, MemoryMappedFileAccess.Read);

Console.WriteLine($"Reading {fileInfo.Length} bytes...");
Console.WriteLine($"{readCount} byte(s) at a time using {threads} thread(s)");

var stopWatch = Stopwatch.StartNew();
int zeroes = 0;
long chunkSize = fs.Length / threads;

Parallel.For(0, threads, chunk =>
{
    long start = chunk * chunkSize;
    long end = (chunk == threads - 1) ? fs.Length : start + chunkSize;

    for (long i = start; i < end; i += readCount)
    {
        if (readCount == 1)
        {
            byte b = accessor.ReadByte(i);
            if (b == 0)
            {
                Interlocked.Increment(ref zeroes);
            }
        }
        else
        {
            byte[] buffer = ArrayPool<byte>.Shared.Rent(readCount);
            int read = accessor.ReadArray(i, buffer, 0, readCount);
            for (int j = 0; j < read; j++)
            {
                if (buffer[j] == 0)
                {
                    Interlocked.Increment(ref zeroes);
                }
            }
            ArrayPool<byte>.Shared.Return(buffer);
        }
    }
});

Console.WriteLine($"Found {zeroes} zero bytes in {file}");
stopWatch.Stop();
Console.WriteLine($"Elapsed time: {stopWatch.Elapsed}");

Java code:

package org.example;

import org.apache.commons.lang3.time.StopWatch;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;

public class App {
    public static void main(String[] args) {
        if (args.length == 0) {
            System.out.println("Usage: MMapTest <file> [readCount=1] [threads=8]");
            return;
        }

        File file = new File(args[0]);
        if (!file.exists()) {
            System.out.println("File not found: " + file);
            return;
        }

        int readCount = args.length > 1 ? Integer.parseInt(args[1]) : 1;
        int threads = args.length > 2 ? Integer.parseInt(args[2]) : 8;

        try (FileInputStream fis = new FileInputStream(file);
             FileChannel fileChannel = fis.getChannel()) {

            MappedByteBuffer accessor = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, file.length());
            System.out.println("Reading " + file.length() + " bytes...");
            System.out.println(readCount + " byte(s) at a time using " + threads + " thread(s)");

            StopWatch stopWatch = StopWatch.createStarted();
            AtomicInteger zeroes = new AtomicInteger(0);
            int chunkSize = (int)file.length() / threads;

            IntStream.range(0, threads).parallel().forEach(chunk -> {
                int start = chunk * chunkSize;
                int end = (chunk == threads - 1) ? (int)file.length() : start + chunkSize;

                for (int i = start; i < end; i += readCount) {
                    if (readCount == 1) {
                        if (accessor.get(i) == 0) {
                            zeroes.incrementAndGet();
                        }
                    } else {
                        byte[] buffer = new byte[readCount];
                        int remainingBytes = (int) file.length() - i;
                        int count = Math.min(readCount, remainingBytes);
                        accessor.position(i);
                        accessor.get(buffer, 0, count);
                        for (int j = 0; j < count; j++) {
                            if (buffer[j] == 0) {
                                zeroes.incrementAndGet();
                            }
                        }
                    }
                }
            });

            stopWatch.stop();
            System.out.println("Found " + zeroes.get() + " zero bytes in " + file);
            System.out.println("Elapsed time: " + stopWatch);
        } catch (FileNotFoundException e) {
            System.err.println("Error: File not found");
        } catch (IOException e) {
            System.err.println("Error reading file: " + e.getMessage());
        }
    }
}

(Note: Java code is not optimized to rent arrays in the multi-byte case.)

Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from dotnet/runtime

All issues in dotnet/runtime

Similar issues

More C# issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.