Почему я не могу использовать 4 ГБ ОЗУ на своем компьютере для обработки менее 2 ГБ информации на C#?

Сценарий: более 1,5 ГБ текстовых и CSV-файлов, которые мне нужно обработать математически. Я пытался использовать SQL Server Express, но загрузка информации, даже при BULK-импорте, занимает очень много времени, и в идеале мне нужно иметь весь набор данных в памяти, чтобы уменьшить операции ввода-вывода на жестком диске.

Существует более 120 000 000 записей, но даже когда я пытаюсь отфильтровать информацию только по одному столбцу (в памяти), мое консольное приложение C# потребляет ~ 3,5 ГБ памяти для обработки всего 125 МБ (700 МБ фактически считывается) текста.

Кажется, что ссылки на строки и массивы строк не собираются сборщиком мусора, даже после установки всех ссылок в значение null и инкапсуляции IDisposables с использованием ключевого слова.

Я думаю, что виновником является метод String.Split(), который создает новую строку для каждого значения, разделенного запятыми.

Вы можете предположить, что я не должен даже читать ненужные* столбцы в массив строк, но это упускает из виду: как я могу поместить этот весь набор данных в память, чтобы я мог обрабатывать его параллельно в С#?

Я мог бы оптимизировать статистические алгоритмы и координировать задачи с помощью сложного алгоритма планирования, но это то, что я надеялся сделать до того, как столкнусь с проблемами памяти, а не из-за них.

Я включил полное консольное приложение, которое имитирует мою среду и должно помочь воспроизвести проблему.

Любая помощь приветствуется. Заранее спасибо.

using System;
using System.Collections.Generic;
using System.Text;
using System.IO;

namespace InMemProcessingLeak
{
    class Program
    {
        static void Main(string[] args)
        {
            //Setup Test Environment. Uncomment Once
            //15000-20000 files would be more realistic
            //InMemoryProcessingLeak.GenerateTestDirectoryFilesAndColumns(3000, 3);
            //GC
            GC.Collect();
            //Demostrate Large Object Memory Allocation Problem (LOMAP)
            InMemoryProcessingLeak.SelectColumnFromAllFiles(3000, 2);
        }
    }

    class InMemoryProcessingLeak
    {
        public static List<string> SelectColumnFromAllFiles(int filesToSelect, int column)
        {
            List<string> allItems = new List<string>();
            int fileCount = filesToSelect;
            long fileSize, totalReadSize = 0;

            for (int i = 1; i <= fileCount; i++)
            {
                allItems.AddRange(SelectColumn(i, column, out fileSize));
                totalReadSize += fileSize;
                Console.Clear();
                Console.Out.WriteLine("Reading file {0:00000} of {1}", i, fileCount);
                Console.Out.WriteLine("Memory = {0}MB", GC.GetTotalMemory(false) / 1048576);
                Console.Out.WriteLine("Total Read = {0}MB", totalReadSize / 1048576);
            }
            Console.ReadLine();
            return allItems;

        }

        //reads a csv file and returns the values for a selected column
        private static List<string> SelectColumn(int fileNumber, int column, out long fileSize)
        {
            string fileIn;
            FileInfo file = new FileInfo(string.Format(@"MemLeakTestFiles/File{0:00000}.txt", fileNumber));
            fileSize = file.Length;
            using (System.IO.FileStream fs = file.Open(FileMode.Open, FileAccess.Read, FileShare.Read))
            {
                using (System.IO.StreamReader sr = new System.IO.StreamReader(fs))
                {
                    fileIn = sr.ReadToEnd();
                }
            }

            string[] lineDelimiter = { "\n" };
            string[] allLines = fileIn.Split(lineDelimiter, StringSplitOptions.None);

            List<string> processedColumn = new List<string>();

            string current;
            for (int i = 0; i < allLines.Length - 1; i++)
            {
                current = GetColumnFromProcessedRow(allLines[i], column);
                processedColumn.Add(current);
            }

            for (int i = 0; i < lineDelimiter.Length; i++) //GC
            {
                lineDelimiter[i] = null;
            }
            lineDelimiter = null;

            for (int i = 0; i < allLines.Length; i++) //GC
            {
                allLines[i] = null;
            }
            allLines = null;
            current = null;

            return processedColumn;
        }

        //returns a row value from the selected comma separated string and column position
        private static string GetColumnFromProcessedRow(string line, int columnPosition)
        {
            string[] entireRow = line.Split(",".ToCharArray());
            string currentColumn = entireRow[columnPosition];
            //GC
            for (int i = 0; i < entireRow.Length; i++)
            {
                entireRow[i] = null;
            }
            entireRow = null;
            return currentColumn;
        }

        #region Generators
        public static void GenerateTestDirectoryFilesAndColumns(int filesToGenerate, int columnsToGenerate)
        {
            DirectoryInfo dirInfo = new DirectoryInfo("MemLeakTestFiles");
            if (!dirInfo.Exists)
            {
                dirInfo.Create();
            }
            Random seed = new Random();

            string[] columns = new string[columnsToGenerate];

            StringBuilder sb = new StringBuilder();
            for (int i = 1; i <= filesToGenerate; i++)
            {
                int rows = seed.Next(10, 8000);
                for (int j = 0; j < rows; j++)
                {
                    sb.Append(GenerateRow(seed, columnsToGenerate));
                }
                using (TextWriter tw = new StreamWriter(String.Format(@"{0}/File{1:00000}.txt", dirInfo, i)))
                {
                    tw.Write(sb.ToString());
                    tw.Flush();
                }
                sb.Remove(0, sb.Length);
                Console.Clear();
                Console.Out.WriteLine("Generating file {0:00000} of {1}", i, filesToGenerate);
            }
        }

        private static string GenerateString(Random seed)
        {
            StringBuilder sb = new StringBuilder();
            int characters = seed.Next(4, 12);
            for (int i = 0; i < characters; i++)
            {
                sb.Append(Convert.ToChar(Convert.ToInt32(Math.Floor(26 * seed.NextDouble() + 65))));
            }
            return sb.ToString();
        }

        private static string GenerateRow(Random seed, int columnsToGenerate)
        {
            StringBuilder sb = new StringBuilder();

            sb.Append(seed.Next());
            for (int i = 0; i < columnsToGenerate - 1; i++)
            {
                sb.Append(",");
                sb.Append(GenerateString(seed));
            }
            sb.Append("\n");

            return sb.ToString();
        }
        #endregion
    }
}

*Эти другие столбцы будут необходимы и будут доступны как последовательно, так и случайным образом в течение жизни программы, поэтому чтение с диска каждый раз требует огромных накладных расходов.

**Примечания по среде: 4 ГБ памяти DDR2 SDRAM 800, Core 2 Duo 2,5 ГГц, .NET Runtime 3.5 SP1, Vista 64.


person exceptionerror    schedule 01.04.2009    source источник
comment
В дополнение к приведенным ниже ответам я заметил, что вы используете List‹T›, основанный на массиве. Насколько я знаю, размер массива удваивается каждый раз, когда вы достигаете его текущей емкости. Так что это может действительно повредить, как только будет достигнут определенный предел.   -  person J. Tihon    schedule 20.11.2011


Ответы (3)


Да, String.Split создает новый объект String для каждой «части» — для этого он и предназначен.

Теперь имейте в виду, что строки в .NET представляют собой Unicode (на самом деле UTF-16), и с учетом накладных расходов объекта стоимость строки в байтах составляет приблизительно 20 + 2*n, где n — количество символов.

Это означает, что если у вас много маленьких строк, это займет много памяти по сравнению с размером задействованных текстовых данных. Например, строка из 80 символов, разделенная на строки 10 x 8 символов, займет в файле 80 байт, но 10 * (20 + 2 * 8) = 360 байт в памяти — увеличение в 4,5 раза!

Я сомневаюсь, что это проблема GC, и я бы посоветовал вам удалить дополнительные операторы, устанавливающие переменные в null, когда в этом нет необходимости - просто проблема слишком большого количества данных.

Я советую читать файл построчно (используя TextReader.ReadLine() вместо TextReader.ReadToEnd()). Ясно, что хранить весь файл в памяти, если вам это не нужно, расточительно.

person Jon Skeet    schedule 01.04.2009
comment
Крайне информативный ответ. Как предположил MSalters, кажется, что мне нужно было бы представлять данные по-другому, если я хочу работать со всей информацией сразу. - person exceptionerror; 01.04.2009
comment
Да, хотя в конечном итоге вы все равно столкнетесь с проблемами. Если вы сможете разработать способ обработки данных в потоковом режиме, решение будет масштабироваться намного лучше. - person Jon Skeet; 01.04.2009
comment
Не могли бы вы порекомендовать что-то вроде push linq, чтобы я мог извлекать реляционную информацию из файлов без зацикливания? - person exceptionerror; 01.04.2009
comment
Это зависит от того, что именно вам нужно сделать, но да, Push LINQ отлично подходит для агрегирования огромных наборов данных. - person Jon Skeet; 01.04.2009

Я бы предложил читать построчно, а не весь файл или блок до 1-2мб.

Обновление:
Судя по комментариям Джона, мне стало любопытно, и я поэкспериментировал с 4 методами:

  • StreamReader.ReadLine (размер буфера по умолчанию и настраиваемый),
  • StreamReader.ReadToEnd
  • Мой метод указан выше.

Чтение файла журнала размером 180 МБ:

  • ReadLine мс: 1937
  • Буфер ReadLine большего размера, ascii мс: 1926
  • ReadToEnd мс: 2151
  • Пользовательская мс: 1415

Пользовательский StreamReader был:

StreamReader streamReader = new StreamReader(fileStream, Encoding.Default, false, 16384)

Буфер StreamReader по умолчанию равен 1024.

По потреблению памяти (актуальный вопрос!) - использовано ~800мб. И метод, который я даю, по-прежнему использует StringBuilder (который использует строку), поэтому потребление памяти не меньше.

person Chris S    schedule 01.04.2009
comment
(Я также настоятельно рекомендую использовать оператор using, чтобы не оставлять поток открытым в случае исключения, и переименовывать bytesRead в characterRead.) - person Jon Skeet; 01.04.2009
comment
Я отредактирую свой ответ, так как противоречу самому себе + обновлю этот трехлетний код вашими предложениями. Размер буфера 16384 был основным отличием от обсуждения на microsoft.public.dotnet.languages.csharp производительности C++ и C# для размера текста. - person Chris S; 01.04.2009
comment
TextReader и StreamReader делают это байт за байтом iirc, что было намного медленнее, когда я проводил некоторые тесты с чтением файлов журнала размером 1,5 МБ - я тоже анализировал каждую строку за раз. - person Chris S; 01.04.2009
comment
TextReader.ReadLine() проверяет символ за символом на наличие символов конца строки, но не вызывает Read для одного символа за раз. (И у StreamReader, и у FileStream есть буферы.) Кстати, использование File.OpenText применяет несколько оптимизаций к создаваемому FileStream, в частности (продолжение) - person Jon Skeet; 01.04.2009
comment
он оптимизирует последовательный доступ. (Недавно я сравнивал смесь операций ввода-вывода и процессора — см. msmvps.com/jon.skeet и последние несколько статей) - person Jon Skeet; 01.04.2009
comment
Это был код, Джон: pastebin.com/m6e8a98bd . Это грубо и готово, но каждый раз я получал примерно одинаковые результаты. Я не знаю, почему это быстрее, но я предполагаю, что не будет ветвления на \n и \r в цикле и декодирования с использованием декодера. - person Chris S; 01.04.2009
comment
4 отдельных файла вместо перезагрузки. Метод ReadLine быстрее для первых 2-х попыток, затем время использования метода char[] значительно уменьшается - person Chris S; 01.04.2009

Современные языки GC используют большие объемы дешевой оперативной памяти для разгрузки задач управления памятью. Это накладывает определенные накладные расходы, но обычному бизнес-приложению в любом случае не требуется столько информации. Многие программы обходятся менее чем тысячей объектов. Ручное управление таким количеством — рутинная работа, но даже тысяча байтов накладных расходов на объект не будут иметь значения.

В вашем случае накладные расходы на объект становятся проблемой. Например, вы можете представить каждый столбец как один объект, реализованный с помощью одной строки и массива целочисленных смещений. Чтобы вернуть одно поле, вы возвращаете подстроку (возможно, как прокладку)

person MSalters    schedule 01.04.2009
comment
Кажется, я исчерпал доступные лучшие практики С#, и ваш ответ указал мне на следующую лучшую вещь. Мне очень нравится C#, но мне интересно, будет ли хорошей идеей изучать C++/CLI и работать с ним в будущем, если я столкнусь с другими задачами, связанными с интенсивным использованием данных, подобными этой. - person exceptionerror; 01.04.2009
comment
Рассмотрите собственный C++; в этих случаях он может быть весьма эффективным. Да, вам придется написать много кода для функций, включенных в C#. Но именно в этом суть; вы один из немногих, кто не может позволить себе стандартные настройки .Net. - person MSalters; 01.04.2009
comment
У меня была очень похожая проблема несколько лет назад, когда я экспериментировал с одноразовой утилитой преобразования .net DB. Я не мог заставить .net работать быстро, но очень простое приложение C++ OLEDB работало очень быстро. Я решил, что библиотека .net, которую я использовал, была очень неэффективной с точки зрения памяти. - person gbjbaanb; 01.04.2009
comment
разница была с 10 часов до 10 минут (примерно). Я понимаю, что, может быть, я испортил это, но я перепробовал все, что мог придумать, чтобы заставить его работать быстрее. Иногда вам просто нужен лучший инструмент для некоторых работ. - person gbjbaanb; 01.04.2009