MemoryMappedFile is up to ~1000x slower than Java for single-byte reads
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
- Domain
- operating-systems, performance
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
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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from dotnet/runtime
-
agentic-workflows untriaged
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
-
area-System.Reflection blocking-clean-ci-optional Known Build Error os-mac-os-x untriaged
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
-
area-CodeGen-coreclr untriaged
Difficulty 1/5 Under an hour Newbie friendliness 92/100
-
agentic-workflows untriaged
Difficulty 1/5 Under an hour Newbie friendliness 78/100
-
area-VM-meta-mono untriaged
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
-
:watch: Not Triaged 11.0 fundamentals/subsvc
Difficulty 2/5 1-3 hours Newbie friendliness 92/100
dotnet/AspNetCore.Docs#37699 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
SubtitleEdit/subtitleedit#15108 · 1 comment ·
-
area/docs-content Bug pulumi/docs
Difficulty 1/5 1-3 hours Newbie friendliness 94/100
-
Create parent directories only after the containment check in InstallHelper.TryExtractToDirectory Open
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
PowerShell/PSResourceGet#2056 ·