Skip to content
This repository has been archived by the owner on Mar 2, 2022. It is now read-only.

Optimize dictionary lookups in C# #32

Merged
merged 4 commits into from
Mar 16, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ For the latest results (run on my machine), see the article's [performance resul

Thanks to these contributors for additional language versions:

* C#: [John Taylor](https://github.com/jftuga) and [Yuriy Ostapenko](https://github.com/uncleyo)
* Rust: [Andrew Gallant](https://github.com/BurntSushi)
* Perl: [Charles Randall](https://github.com/charles-randall)
* Ruby: [Bill Mill](https://github.com/llimllib), with input from [Niklas](https://github.com/nhh)
Expand Down
2 changes: 1 addition & 1 deletion benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def time_run(cmdline):
('C++', './simple-cpp', './optimized-cpp', 'optimized by Jussi Pakkanen'),
('Python', 'python3 simple.py', 'python3 optimized.py', ''),
('Ruby', 'ruby simple.rb', 'ruby optimized.rb', 'by Bill Mill'),
('C#', './simple-cs', None, 'original by John Taylor'),
('C#', './csharp/simple/bin/Release/net5.0/simple', './csharp/optimized/bin/Release/net5.0/optimized', 'by John Taylor and Yuriy Ostapenko'),
('AWK', 'gawk -f simple.awk', 'mawk -f optimized.awk', 'optimized uses `mawk`'),
('Forth', '../gforth/gforth-fast simple.fs', '../gforth/gforth-fast optimized.fs', ''),
('Shell', 'bash simple.sh', 'bash optimized.sh', 'optimized does `LC_ALL=C sort -S 2G`'),
Expand Down
2 changes: 2 additions & 0 deletions csharp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
bin
obj
42 changes: 42 additions & 0 deletions csharp/optimized/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Original version by John Taylor

using System;
using System.IO;
using System.Collections.Generic;
using System.Linq;

class Program
{
public sealed class Ref<T> {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small note: The BCL supplies StrongBox<T> (under System.Runtime.CompilerServices) that fulfils the exact same purpose as this type.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL, thanks!

Copy link

@calledude calledude Mar 17, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of having wrapped the integer anyway? My initial thought was that this would actually make the program less performant, because a reference type lives on the heap, whereas an int lives on the stack. Clearly there must be something I'm missing here?

Copy link
Contributor

@erikbozic erikbozic Mar 17, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@calledude it's a reference type, so we don't copy a new int every time we get it out of the dictionary - but instead increment the existing one. If you try it without you will see all counts are just 1.
In a more general sense: we're storing a pointer to integer instead of only the integer itself.

Otherwise we would need to get it out of the dictionary, increment it and then set the new incremented value back.

Copy link

@calledude calledude Mar 17, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about a ref struct then? This would force it to still live on the stack and we would have a pointer to it, perhaps that would make it even more performant? I assume there's a tradeoff in the current solution where having it on the stack does not outweigh the cost of copying the value all the time?

Edit; Nvm, ref structs can't be used as generic type arguments.

Copy link
Contributor

@erikbozic erikbozic Mar 17, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest, I haven't looked into that part too much.
A trace I did earlier shows that the actual lookup takes the most time, followed by string creation. (not sure, if the latest code version, but should be close)
image
So I'm focusing on that first.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

FWIW; According to BenchmarkDotNet int is still faster.

Copy link
Contributor Author

@yuriyostapenko yuriyostapenko Mar 17, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW; According to BenchmarkDotNet int is still faster.

@calledude, I'm not sure what exactly you are measuring there, but if you simply removed wrapping object and still use the rest of the optimized code as-is (that is, using TryGetValue), your faster code does not work correctly. As @erikbozic already pointed out - all your counters will never increment and always stay at 1.

Yes, extra object indirection adds overhead, but this code is faster than code in "simple" without it exactly because code without it will have to do hash lookup twice: first, to read value and then to store it back again. That is because current Dictionary API does not support atomic increment or in-place value modification.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As for you question on ref struct - the whole Dictionary lives on the heap and anything you store there has to be boxed and put on a heap, which ref struct cannot be.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just double checked and I could've sworn I had made sure I was actually incrementing the value correctly, but I guess not then.

Not my proudest moment... :) Sorry about that!

public Ref(T initialValue) {
Value = initialValue;
}
public T Value { get; set; }
}

static void Main(string[] args)
{
var counts = new Dictionary<string, Ref<int>>();
string line;
while ((line = Console.ReadLine()) != null)
{
line = line.ToLower();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could save this potential string allocation by instantiating the dictionary with one of the *IgnoreCase StringComparers. Additionally, that would show the explicit choice of if the algorithm is culture-aware or not.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest I intentionally did not include any of those optimizations that were plentiful in the many parallel PRs :)

var words = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
string word;
for (int i = 0; i < words.Length; i++)
{
word = words[i];
if (!counts.TryGetValue(word, out var count)) {
counts.Add(word, new Ref<int>(1));
} else {
count.Value += 1;
}
}
}
var ordered = counts.OrderByDescending(pair => pair.Value.Value);
foreach (var entry in ordered)
{
Console.WriteLine("{0} {1}", entry.Key, entry.Value.Value);
}
}
}
8 changes: 8 additions & 0 deletions csharp/optimized/optimized.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>

</Project>
29 changes: 29 additions & 0 deletions csharp/simple/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Original version by John Taylor

using System;
using System.IO;
using System.Collections.Generic;
using System.Linq;

class Program
{
static void Main(string[] args)
{
var counts = new Dictionary<string, int>();
string line;
while ((line = Console.ReadLine()) != null)
{
line = line.ToLower();
var words = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
foreach (string word in words)
{
counts[word] = counts.GetValueOrDefault(word, 0) + 1;
}
}
var ordered = counts.OrderByDescending(pair => pair.Value);
foreach (var entry in ordered)
{
Console.WriteLine("{0} {1}", entry.Key, entry.Value);
}
}
}
8 changes: 8 additions & 0 deletions csharp/simple/simple.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>

</Project>
10 changes: 7 additions & 3 deletions test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,13 @@ crystal build --release simple.cr -o simple-cr
git diff --exit-code output.txt

echo C# simple
csc -optimize -out:simple-cs simple.cs
chmod +x simple-cs
./simple-cs <kjvbible_x10.txt | python3 normalize.py >output.txt
dotnet build ./csharp/simple -c Release
./csharp/simple/bin/Release/net5.0/simple <kjvbible_x10.txt | python3 normalize.py >output.txt
git diff --exit-code output.txt

echo C# optimized
dotnet build ./csharp/optimized -c Release
./csharp/optimized/bin/Release/net5.0/optimized <kjvbible_x10.txt | python3 normalize.py >output.txt
git diff --exit-code output.txt

echo Swift simple
Expand Down