大文本文件中的单词频率

时间:2015-05-16 19:56:12

标签: c# multithreading performance algorithm data-structures

我正在尝试阅读一个大文本文件并输出其中的不同单词及其计数。到目前为止,我已尝试了几次尝试,这是迄今为止我提出的最快的解决方案。

private static readonly char[] separators = { ' ' };

public IDictionary<string, int> Parse(string path)
{
    var wordCount = new Dictionary<string, int>();

    using (var fileStream = File.Open(path, FileMode.Open, FileAccess.Read))
    using (var streamReader = new StreamReader(fileStream))
    {
        string line;
        while ((line = streamReader.ReadLine()) != null)
        {
            var words = line.Split(separators, StringSplitOptions.RemoveEmptyEntries);

            foreach (var word in words)
            {
                if (wordCount.ContainsKey(word))
                {
                    wordCount[word] = wordCount[word] + 1;
                }
                else
                {
                    wordCount.Add(word, 1);
                }
            }
        }
    }

    return wordCount;
}

我如何衡量我的解决方案

我有一个200MB的文本,我知道(通过文本编辑器)的总字数。我正在使用Stopwatch class并计算单词以确保准确性并测量所花费的时间。到目前为止,大约需要9秒钟。

其他尝试

  • 我试图利用多线程来分离出来的工作 TPL库。这包括批处理多行,发送出去 处理批处理行到单独的任务并锁定 字典中的读/写操作。然而,这似乎不是 为我提供任何性能改进。
  • 花了大约30秒。我怀疑锁定读取/写入 字典太昂贵,无法获得任何表现。
  • 我还看了ConcurrentDictionary类型,但是 AddOrUpdate方法确实需要调用代码来处理 从我的理解同步,并没有带来任何表现 益处。

我相信有更快的方法来实现这一目标!是否有更好的数据结构可用于此问题?

欢迎对我的解决方案提出任何建议/批评 - 尝试在这里学习和改进!

干杯。

更新:这是我正在使用的测试文件的link

6 个答案:

答案 0 :(得分:12)

我能给出的最佳答案是衡量,衡量和衡量。 Stopwatch很高兴能够感受到花费时间的地方,但最终你最终会用它来大量喷洒你的代码,否则你将不得不为此找到更好的工具。我建议为此获得一个专用的分析器工具,有许多可用于C#和.NET。

我已经设法通过三个步骤削减了总运行时间的43%。

首先我测量了你的代码并得到了这个:

Original code measurements

这似乎表明我们可以尝试打击两个热点:

  1. 字符串拆分(SplitInternal)
  2. 字典维护(FindEntry,Insert,get_Item)
  3. 花费的最后一段时间是阅读文件,我真的怀疑我们可以通过更改代码的这一部分来获得更多。这里的另一个答案提到使用特定的缓冲区,我试过这个并且无法获得可测量的差异。

    第一个,字符串拆分,有点简单,但涉及将对string.Split的一个非常简单的调用重写为更多的代码。处理一行的循环我重写了这个:

    while ((line = streamReader.ReadLine()) != null)
    {
        int lastPos = 0;
        for (int index = 0; index <= line.Length; index++)
        {
            if (index == line.Length || line[index] == ' ')
            {
                if (lastPos < index)
                {
                    string word = line.Substring(lastPos, index - lastPos);
                    // process word here
                }
                lastPos = index + 1;
            }
        }
    }
    

    然后我重写了一个单词的处理:

    int currentCount;
    wordCount.TryGetValue(word, out currentCount);
    wordCount[word] = currentCount + 1;
    

    这取决于:

    1. TryGetValue比检查单词是否存在然后检索其当前计数便宜
    2. 如果TryGetValue无法获取值(键不存在),那么它会将currentCount变量初始化为其默认值,即0.这意味着我们并不真正需要检查这个词是否确实存在。
    3. 我们可以通过索引器向字典添加新单词(它将覆盖现有值或向字典添加新键+值)
    4. 最终循环因此如下所示:

      while ((line = streamReader.ReadLine()) != null)
      {
          int lastPos = 0;
          for (int index = 0; index <= line.Length; index++)
          {
              if (index == line.Length || line[index] == ' ')
              {
                  if (lastPos < index)
                  {
                      string word = line.Substring(lastPos, index - lastPos);
                      int currentCount;
                      wordCount.TryGetValue(word, out currentCount);
                      wordCount[word] = currentCount + 1;
                  }
                  lastPos = index + 1;
              }
          }
      }
      

      新测量结果显示:

      new measurement

      详细说明:

      1. 我们从6876ms到5013ms
      2. 我们在SplitInternalFindEntryget_Item
      3. 上花费的时间浪费了
      4. 我们花了很多时间在TryGetValueSubstring
      5. 以下是不同的细节:

        difference

        正如你所看到的,我们失去了比获得新时间更多的时间,这导致了净改善。

        然而,我们可以做得更好。我们在这里进行2次字典查找,包括计算单词的哈希码,并将其与字典中的键进行比较。第一次查找是TryGetValue的一部分,第二次是wordCount[word] = ...的一部分。

        我们可以通过在字典中创建更智能的数据结构来删除第二个字典查找,代价是使用更多堆内存。

        我们可以使用Xanatos将对象存储在对象中的技巧,以便我们可以删除第二个字典查找:

        public class WordCount
        {
            public int Count;
        }
        
        ...
        
        var wordCount = new Dictionary<string, WordCount>();
        
        ...
        
        string word = line.Substring(lastPos, index - lastPos);
        WordCount currentCount;
        if (!wordCount.TryGetValue(word, out currentCount))
            wordCount[word] = currentCount = new WordCount();
        currentCount.Count++;
        

        这只会从字典中检索计数,增加1次额外出现不涉及字典。该方法的结果也会更改为将此WordCount类型作为字典的一部分返回,而不仅仅是int

        净结果:节省约43%。

        final results

        最后一段代码:

        public class WordCount
        {
            public int Count;
        }
        
        public static IDictionary<string, WordCount> Parse(string path)
        {
            var wordCount = new Dictionary<string, WordCount>();
        
            using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None, 65536))
            using (var streamReader = new StreamReader(fileStream, Encoding.Default, false, 65536))
            {
                string line;
                while ((line = streamReader.ReadLine()) != null)
                {
                    int lastPos = 0;
                    for (int index = 0; index <= line.Length; index++)
                    {
                        if (index == line.Length || line[index] == ' ')
                        {
                            if (lastPos < index)
                            {
                                string word = line.Substring(lastPos, index - lastPos);
                                WordCount currentCount;
                                if (!wordCount.TryGetValue(word, out currentCount))
                                    wordCount[word] = currentCount = new WordCount();
                                currentCount.Count++;
                            }
                            lastPos = index + 1;
                        }
                    }
                }
            }
        
            return wordCount;
        }
        

答案 1 :(得分:6)

您的方法似乎符合大多数人的解决方法。您是正确的注意到使用多线程并没有带来任何显着的收益,因为瓶颈很可能是IO绑定的,无论您拥有什么样的硬件,您的读取速度都不会超过您的硬件支持。

如果你真的在寻找速度改进(我怀疑你会得到任何改进),你可以尝试实现一个生产者 - 消费者模式,其中一个线程读取文件而其他线程处理这些行(可能然后并行化检查一行中的单词)。这里的折衷是你添加了更复杂的代码以换取边际改进(只有基准测试才能确定这一点)。

http://en.wikipedia.org/wiki/Producer%E2%80%93consumer_problem

编辑:还可以查看ConcurrentDictionary

答案 2 :(得分:6)

我已经获得了相当多的收益(从200毫秒的文件上25秒到20秒)只是改变了:

int cnt;

if (wordCount.TryGetValue(word, out cnt))
{
    wordCount[word] = cnt + 1;
}
else
....

基于ConcurrentDictionary<>Parallel.ForEach的变体(使用IEnumerable<>重载)。请注意,我使用的int使用InterlockedInt来增加自身,而不是使用Interlocked.Increment。作为参考类型,它可以与ConcurrentDictionary<>.GetOrAdd ...

一起正常工作
public class InterlockedInt
{
    private int cnt;

    public int Cnt
    {
        get
        {
            return cnt;
        }
    }

    public void Increment()
    {
        Interlocked.Increment(ref cnt);
    }
}

public static IDictionary<string, InterlockedInt> Parse(string path)
{
    var wordCount = new ConcurrentDictionary<string, InterlockedInt>();

    Action<string> action = line2 =>
    {
        var words = line2.Split(separators, StringSplitOptions.RemoveEmptyEntries);

        foreach (var word in words)
        {
            wordCount.GetOrAdd(word, x => new InterlockedInt()).Increment();
        }
    };

    IEnumerable<string> lines = File.ReadLines(path);
    Parallel.ForEach(lines, action);

    return wordCount;
}

请注意,使用Parallel.ForEach的效率低于直接使用每个物理核心的一个线程(您可以在历史记录中看到)。虽然这两种解决方案在我的PC上只需不到10秒的“挂机”时钟,Parallel.ForEach使用55秒的CPU时间与Thread解决方案的33秒。

另一个诀窍是价值5-10%左右:

public static IEnumerable<T[]> ToBlock<T>(IEnumerable<T> source, int num)
{
    var array = new T[num];
    int cnt = 0;

    foreach (T row in source)
    {
        array[cnt] = row;
        cnt++;

        if (cnt == num)
        {
            yield return array;
            array = new T[num];
            cnt = 0;
        }
    }

    if (cnt != 0)
    {
        Array.Resize(ref array, cnt);
        yield return array;
    }
}

您可以在数据包中“分组”行(选择介于10和100之间的数字),以便减少线程内通信。然后工人必须对收到的行进行foreach

答案 3 :(得分:1)

如果您正在尝试计算特定单词,则可以使用strtok函数 linked here并将每个单词与您正在评估的单词进行比较,我认为这不是非常昂贵但我从未尝试使用大文件夹...

答案 4 :(得分:1)

我建议您将流缓冲区大小设置得更大,并匹配:

    using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 8192))
    using (var streamReader = new StreamReader(fileStream, Encoding.UTF8, false, 8192))

首先,您的代码导致缓冲区太小而无法完成此类工作。其次,由于阅读器的缓冲区小于流的缓冲区,数据首先被复制到流的缓冲区,然后被复制到阅读器的缓冲区。这可以成为您正在进行的工作类型的性能驱逐者。

当缓冲区大小匹配时,将永远不会使用流的缓冲区 - 实际上它永远不会被分配。

答案 5 :(得分:1)

使用200mb的文本文件,以下内容在我的机器上花了不到5秒钟。

    class Program
{
    private static readonly char[] separators = { ' ' };
    private static List<string> lines;
    private static ConcurrentDictionary<string, int> freqeuncyDictionary;

    static void Main(string[] args)
    {
        var stopwatch = new System.Diagnostics.Stopwatch();
        stopwatch.Start();

        string path = @"C:\Users\James\Desktop\New Text Document.txt";
        lines = ReadLines(path);
        ConcurrentDictionary<string, int> test = GetFrequencyFromLines(lines);

        stopwatch.Stop();
        Console.WriteLine(@"Complete after: " + stopwatch.Elapsed.TotalSeconds);
    }

    private static List<string> ReadLines(string path)
    {
        lines = new List<string>();
        using (var fileStream = File.Open(path, FileMode.Open, FileAccess.Read))
        {
            using (var streamReader = new StreamReader(fileStream))
            {
                string line;
                while ((line = streamReader.ReadLine()) != null)
                {
                    lines.Add(line);
                }
            }
        }
        return lines;            
    }

    public static ConcurrentDictionary<string, int> GetFrequencyFromLines(List<string> lines)
    {
        freqeuncyDictionary = new ConcurrentDictionary<string, int>();
        Parallel.ForEach(lines, line =>
        {
            var words = line.Split(separators, StringSplitOptions.RemoveEmptyEntries);

            foreach (var word in words)
            {
                if (freqeuncyDictionary.ContainsKey(word))
                {
                    freqeuncyDictionary[word] = freqeuncyDictionary[word] + 1;
                }
                else
                {
                    freqeuncyDictionary.AddOrUpdate(word, 1, (key, oldValue) => oldValue + 1);
                }
            }
        });

        return freqeuncyDictionary;
    }
}