Слишком низкая загрузка ЦП многопоточным Java-приложением в Windows

Я работаю над Java-приложением для решения класса задач численной оптимизации, а точнее крупномасштабных задач линейного программирования. Одну проблему можно разбить на более мелкие подзадачи, которые можно решать параллельно. Поскольку подзадач больше, чем ядер ЦП, я использую ExecutorService и определяю каждую подзадачу как Callable, которая передается в ExecutorService. Решение подзадачи требует вызова собственной библиотеки — в данном случае решателя линейного программирования.

Проблема

Я могу запускать приложение в системах Unix и Windows, имеющих до 44 физических ядер и до 256 ГБ памяти, но время вычислений в Windows на порядок выше, чем в Linux для больших задач. Windows не только требует значительно больше памяти, но и загрузка ЦП со временем падает с 25% в начале до 5% через несколько часов. Вот скриншот диспетчера задач в Windows:

Использование ЦП диспетчера задач

Наблюдения

  • Время решения больших экземпляров общей проблемы варьируется от часов до дней и требует до 32 г памяти (в Unix). Время решения подзадачи находится в диапазоне мс.
  • Я не сталкиваюсь с этой проблемой при небольших проблемах, решение которых занимает всего несколько минут.
  • Linux использует оба сокета «из коробки», тогда как Windows требует, чтобы я явно активировал чередование памяти в BIOS, чтобы приложение использовало оба ядра. Независимо от того, делаю ли я это, это не влияет на ухудшение общей загрузки ЦП с течением времени.
  • Когда я смотрю на потоки в VisualVM, все потоки пула работают, ни один из них не находится в ожидании или что-то еще.
  • Согласно VisualVM, 90% процессорного времени тратится на вызов встроенной функции (решение небольшой линейной программы).
  • Сборка мусора не является проблемой, поскольку приложение не создает и не разыменовывает большое количество объектов. Кроме того, кажется, что большая часть памяти выделена вне кучи. 4 г кучи достаточно в Linux и 8 г в Windows для самого большого экземпляра.

Что я пробовал

  • всевозможные аргументы JVM, высокий XMS, высокий метапространство, флаг UseNUMA, другие GC.
  • разные JVM (Hotspot 8, 9, 10, 11).
  • различные нативные библиотеки различных решателей линейного программирования (CLP, Xpress, Cplex, Gurobi).

Вопросы

  • Что определяет разницу в производительности между Linux и Windows для большого многопоточного Java-приложения, интенсивно использующего собственные вызовы?
  • Есть ли что-то, что я могу изменить в реализации, что помогло бы Windows, например, следует ли мне избегать использования ExecutorService, который получает тысячи Callables, и делать что вместо этого?

person Nils    schedule 14.11.2019    source источник
comment
Вы пробовали ForkJoinPool вместо ExecutorService? 25% загрузки ЦП действительно мало, если ваша проблема связана с ЦП.   -  person Karol Dowbecki    schedule 14.11.2019
comment
Я не. Почему вы думаете, что это решит проблему?   -  person Nils    schedule 14.11.2019
comment
Ваша проблема звучит так, будто процессор должен загружаться на 100%, а у вас 25%. Для некоторых задач ForkJoinPool более эффективен, чем планирование вручную.   -  person Karol Dowbecki    schedule 14.11.2019
comment
Перебирая версии Hotspot, убедитесь, что вы используете серверную, а не клиентскую версию? Какова у вас загрузка ЦП в Linux? Кроме того, время безотказной работы Windows в несколько дней впечатляет! В чем твой секрет? :П   -  person erickson    schedule 14.11.2019
comment
Да, он использует 100% (или близко к этому) в Linux, но не в Windows.   -  person Nils    schedule 14.11.2019
comment
Может быть, на вашем сервере Windows установлен антивирус? Можете ли вы протестировать на сервере с минимальной установкой Windows?   -  person Karol Dowbecki    schedule 14.11.2019
comment
@erickson Вы имеете в виду флаг сервера? Я пробовал это, это не имеет никакого эффекта.   -  person Nils    schedule 14.11.2019
comment
@Karol Dowbecki: я запускал его на нескольких AWS Windows 2019, вплоть до дорогого «голого железа», а также на рабочей станции. За исключением того, что по умолчанию предоставляется через Windows, ни один из них не имеет работающих антивирусных сканеров.   -  person Nils    schedule 14.11.2019
comment
Возможно, попробуйте использовать Xperf для создания FlameGraph. Это может дать вам некоторое представление о том, что делает ЦП (надеюсь, как в пользовательском режиме, так и в режиме ядра), но я никогда не делал этого в Windows.   -  person Karol Dowbecki    schedule 14.11.2019
comment
@KarolDowbecki Спасибо за ссылку. Я проверю, но CPU занят вызовом нативной функции более 90% времени.   -  person Nils    schedule 14.11.2019
comment
Хорошо, вы имеете в виду, что 90% из 25% ЦП, которые использует процесс, находятся в собственном вызове, или что ЦП на 100% с 90% в собственном коде и 10% в Java?   -  person erickson    schedule 15.11.2019
comment
@erickson 90% из 25% ЦП (в Windows), а также 90% из 100% ЦП (в Linux) находятся в собственном вызове   -  person Nils    schedule 15.11.2019
comment
@KarolDowbecki ForkJoinPool не приносит облегчения.   -  person Nils    schedule 16.11.2019
comment
Вы также пробовали разные распределители памяти в своем решателе?   -  person aventurin    schedule 17.11.2019
comment
@aventurin Как мне попробовать разные распределители памяти и что это такое?   -  person Nils    schedule 17.11.2019
comment
Они отвечают за динамическое выделение памяти (malloc, realloc). Когда ваш код решателя использует malloc, realloc и т. д., то в длительном процессе тип распределителя может иметь значение. Это также может объяснить различия в различных операционных системах из-за фрагментации памяти и частоты попаданий в кэш. См. en.wikipedia.org/wiki/C_dynamic_memory_allocation.   -  person aventurin    schedule 17.11.2019
comment
Трудно догадаться без примера кода. Как вы читаете/записываете данные, которые сохраняете? Возможно, проблема заключается в некоторых дополнительных операциях чтения/записи. Или попробуйте отключить все функции защитника Windows — иногда это может сильно замедлить работу некоторых приложений.   -  person GotoFinal    schedule 18.11.2019
comment
@GotoFinal Я подумаю об этом еще немного, но довольно сложно выделить фрагмент кода (или даже несколько). Приложение работает в памяти, а Защитник Windows отключен.   -  person Nils    schedule 19.11.2019
comment
@Nils, оба прогона (unix/win) используют один и тот же интерфейс для вызова нативной библиотеки? Я спрашиваю, потому что это похоже на другое. Например: win использует jna, linux jni.   -  person S.R.    schedule 20.11.2019
comment
Хм, @Nils не указал, использует ли он разные собственные интерфейсы, но похоже, что это так. Определенно может быть причиной разницы на порядок величин... -performance-compare-to-custom-jni" rel="nofollow noreferrer">github.com/java-native-access/jna/blob/master/www/ . Но в любом случае я думаю, что он упомянул бы об этом, если бы использовал разные библиотеки для Linux и Windows.   -  person Christoph John    schedule 21.11.2019
comment
@ С.Р. Обе реализации используют один и тот же интерфейс в обеих системах.   -  person Nils    schedule 21.11.2019
comment
@ChristophJohn Вот ссылка на одну из библиотек, которые я использую. Профилировщик не идентифицирует повторяющиеся вызовы функций как узкое место. Большая часть времени вычислений проводится внутри CLPNative.javaclpInitialSolve(Pointer<CLPSimplex>)   -  person Nils    schedule 21.11.2019
comment
У меня действительно нет решения без проверки всего этого кода. Но вот некоторые вещи, которые я задаю себе: 1. Вы говорите, что сборка мусора не является проблемой, поскольку приложение не создает и не ссылается на множество объектов. OTOH, вы говорите, что время решения подзадачи находится в диапазоне мс, а время решения общей проблемы - от часов до дней. Так разве вы на самом деле не создаете множество объектов (для подзадач)? Может быть, вы могли бы активировать ведение журнала GC, чтобы проверить. Или вы уже проверили в jvisualvm, что проблема не в GC?   -  person Christoph John    schedule 22.11.2019
comment
2. Поскольку вы довольно активно используете встроенную память, я бы посоветовал вам выполнить отслеживание для проверки. Пример здесь: baeldung.com/native- memory-tracking-in-jvm#9-nmt-over-time Обратите внимание, что включение NMT приведет к падению производительности примерно на 5-10% (docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/) 3. Вы сразу передать все подзадачи Исполнителю? Используете ли вы блокирующую очередь задач? Используете ли вы ограниченную очередь задач?   -  person Christoph John    schedule 22.11.2019
comment
4. Я предполагаю, что вы получаете результаты всех Callables, которые вы отправляете? Или вы отменяете некоторые Callables? 5. Вы часто регистрируете свое приложение, так что нагрузка ввода-вывода может замедлять процесс? Удачи ;)   -  person Christoph John    schedule 22.11.2019
comment
Попробуйте это, чтобы увидеть, сколько ядер, по мнению Java, имеет Runtime.getRuntime(). availableProcessors();   -  person David Lilljegren    schedule 22.11.2019
comment
@ChristophJohn 1. Подзадачи не разыменовываются, а используются повторно, поэтому GC действительно не проблема; 2. Изучу это; 3. Я использую BlockingQueue; 4. Я использую callable только для перехвата исключений при сбое подзадачи и не отменяю их, но runnables ведут себя так же; 5. Нет. Спасибо :)   -  person Nils    schedule 23.11.2019
comment
Я столкнулся с тем, что выглядит очень похоже на ту же проблему, и пока нет решения. У меня есть тривиально распараллеливаемая рабочая нагрузка, я отправляю много небольших задач в службу-исполнитель (каждая занимает миллисекунды), с BlockingQueue в ней, поэтому ограничьте количество отправленных задач, загрузка ЦП зависла на уровне около 25% в Windows, с обработка на несколько порядков медленнее (до 1000 раз медленнее в Windows), при этом подавляющая часть работы выполняется в GZIP. Я собираюсь работать над созданием небольшого тестового примера, который воспроизводит его, поскольку приложение уже довольно маленькое. Любые решения?   -  person barteks2x    schedule 27.06.2020
comment
@ barteks2x Трудно сказать, действительно ли это одна и та же проблема, потому что GZIP, вероятно, включает операции с жестким диском, которые linux / win могут обрабатывать по-разному. В моем случае я предполагаю, что проблема заключается в высокой нагрузке на память, которая происходит вне кучи, что замедляет многопоточность Java в Windows. Я обошел эту проблему, уменьшив объем памяти.   -  person Nils    schedule 28.06.2020
comment
@Nils Хотя я не могу сказать наверняка, я обнаружил 2 проблемы, о которых сообщалось и которые были отмечены как исправленные навсегда bugs.java.com/bugdatabase/view_bug.do?bug_id=5043044 и bugs.java.com/bugdatabase/view_bug.do?bug_id=6206933, которые могут показаться актуальными. Это очень умозрительно, но способ реализации доступа к массиву через JNI может вызвать проблемы с участием нескольких потоков. И чтобы быть ясным в моем случае GZIP - все операции ввода-вывода выполняются отдельно и не являются узким местом.   -  person barteks2x    schedule 29.06.2020


Ответы (4)


Для Windows количество потоков на процесс ограничено адресным пространством процесса (см. также Марк Руссинович - Расширение границ Windows: процессы и потоки). Думайте, что это вызывает побочные эффекты, когда оно приближается к ограничениям (замедление переключения контекста, фрагментация...). Для Windows я бы попытался разделить нагрузку на набор процессов. Для аналогичной проблемы, с которой я столкнулся много лет назад, я реализовал библиотеку Java, чтобы сделать это более удобно (Java 8), посмотрите, если хотите: Библиотека для создания задач во внешнем процессе.

person geri    schedule 23.11.2019
comment
Это выглядит очень интересно! Я немного не решаюсь зайти так далеко (пока) по двум причинам: 1) будут накладные расходы на сериализацию и отправку объектов через сокеты; 2) если я хочу сериализовать все, что включает в себя все зависимости, которые связаны в задаче - было бы немного работы, чтобы переписать код - тем не менее, спасибо за полезную ссылку (ссылки). - person Nils; 24.11.2019
comment
Я полностью разделяю ваши опасения, и изменение кода потребует некоторых усилий. При перемещении по графу вам нужно будет ввести пороговое значение для количества потоков, когда пришло время разделить работу на новый подпроцесс. Для решения 2) взгляните на файл с отображением памяти Java (java.nio.MappedByteBuffer), с помощью которого вы можете эффективно обмениваться данными между процессами, например данными вашего графика. Удачи :) - person geri; 30.11.2019

Похоже, что Windows кэширует некоторую память в файл подкачки после того, как ее не трогали в течение некоторого времени, и поэтому процессор ограничен скоростью диска.

Вы можете проверить это с помощью Process Explorer и проверить, сколько памяти кешируется.

person Jew    schedule 21.11.2019
comment
Вы думаете? Свободной памяти достаточно. Почему Windows начала подкачку? В любом случае, спасибо. - person Nils; 23.11.2019
comment
По крайней мере у меня на ноуте windows подкачивает иногда свернутые приложения, даже при достаточном количестве памяти - person Jew; 24.11.2019

Я думаю, что эта разница в производительности связана с тем, как O.S. управляет потоками. JVM скрывает все различия ОС. Об этом можно прочитать на многих сайтах, например это, например. Но это не значит, что разница исчезает.

Я полагаю, вы работаете на Java 8+ JVM. В связи с этим предлагаю вам попробовать использовать возможности потокового и функционального программирования. Функциональное программирование очень полезно, когда у вас много небольших независимых задач, и вы хотите легко переключиться с последовательного выполнения на параллельное. Хорошей новостью является то, что вам не нужно определять политику для определения количества потоков, которыми вы должны управлять (как в случае с ExecutorService). Например (взято из здесь):

package com.mkyong.java8;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
import java.util.stream.Stream;

public class ParallelExample4 {

    public static void main(String[] args) {

        long count = Stream.iterate(0, n -> n + 1)
                .limit(1_000_000)
                //.parallel()   with this 23s, without this 1m 10s
                .filter(ParallelExample4::isPrime)
                .peek(x -> System.out.format("%s\t", x))
                .count();

        System.out.println("\nTotal: " + count);

    }

    public static boolean isPrime(int number) {
        if (number <= 1) return false;
        return !IntStream.rangeClosed(2, number / 2).anyMatch(i -> number % i == 0);
    }

}

Результат:

Для обычных потоков это занимает 1 минуту 10 секунд. Для параллельных потоков это занимает 23 секунды. P.S Проверено с i7-7700, 16G RAM, Windows 10

Итак, я предлагаю вам прочитать о программировании функций, потоках, лямбда-функциях в Java и попробовать реализовать небольшое количество тестов с вашим кодом (адаптированным для работы в этом новом контексте).

person xcesco    schedule 22.11.2019
comment
Я использую потоки в других частях программы, но в этом случае задачи создаются при обходе графа. Я бы не знал, как обернуть это с помощью потоков. - person Nils; 23.11.2019
comment
Можете ли вы пройти по графу, построить список, а затем использовать потоки? - person xcesco; 24.11.2019
comment
Параллельные потоки — это всего лишь синтаксический сахар для ForkJoinPool. Это я пробовал (см. комментарий @KarolDowbecki выше). - person Nils; 24.11.2019

Не могли бы вы опубликовать системную статистику? Диспетчер задач достаточно хорош, чтобы дать некоторую подсказку, если это единственный доступный инструмент. Он может легко определить, ожидают ли ваши задачи ввода-вывода, что звучит как виновник, исходя из того, что вы описали. Это может быть связано с определенной проблемой управления памятью, или библиотека может записать какие-то временные данные на диск и т. д.

Когда вы говорите, что 25% загрузки ЦП, вы имеете в виду, что только несколько ядер заняты одновременной работой? (Возможно, что все ядра работают время от времени, но не одновременно.) Не могли бы вы проверить, сколько потоков (или процессов) реально создано в системе? Всегда ли число больше, чем количество ядер?

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

person Xiao-Feng Li    schedule 23.11.2019
comment
Я добавил скриншот диспетчера задач для выполнения, который представляет эту проблему. Само приложение создает столько потоков, сколько физических ядер на машине. Java вносит в эту цифру немногим более 50 потоков. Как уже было сказано, VisualVM говорит, что все потоки заняты (зеленый). Они просто не нагружают процессор до предела в Windows. Делают на линуксе. - person Nils; 24.11.2019
comment
@Nils Я подозреваю, что на самом деле у вас не все потоки заняты одновременно, а на самом деле только 9-10 из них. Они распределяются случайным образом по всем ядрам, поэтому у вас в среднем 9/44 = 20% использования. Можете ли вы использовать потоки Java напрямую, а не ExecutorService, чтобы увидеть разницу? Несложно создать 44 потока, каждый из которых будет брать Runnable/Callable из пула/очереди задач. (Хотя VisualVM показывает, что все потоки Java заняты, реальность может заключаться в том, что 44 потока планируются быстро, так что все они получают возможность запускаться в период выборки VisualVM.) - person Xiao-Feng Li; 25.11.2019
comment
Это мысль и то, что я действительно сделал в какой-то момент. В моей реализации я также сделал так, чтобы собственный доступ был локальным для каждого потока, но это не имело никакого значения. - person Nils; 25.11.2019