Skip to content

Перф: один общий обход AST вместо ~116 независимых обходов диагностиками #4316

Description

@nixel2007

Проблема

Каждая AST-диагностика сегодня — самостоятельный обходчик дерева. DefaultDiagnosticComputer.compute() (diagnostics/DefaultDiagnosticComputer.java:62) сабмитит каждую диагностику отдельной задачей в diagnosticComputerExecutor, а AbstractVisitorDiagnostic.getDiagnostics() (diagnostics/AbstractVisitorDiagnostic.java:43) делает visitFile(documentContext.getAst()) — полный обход AST.

Итого на один документ приходится больше сотни полных обходов одного и того же дерева.

Замеры структуры (grep по diagnostics/, 205 файлов)

Корпус данных Полных обходов на документ
BSL AST (AbstractVisitorDiagnostic 89 + AbstractFindMethodDiagnostic 16 + ветки AbstractMagicValue/AbstractExecuteExternalCode/AbstractCommonModuleName + 4 листенера) ~116
SDBL AST из getQueries() (15 визиторов + 2 листенера) 17 (по каждому запросу в файле)
Expression Tree (AbstractExpressionTreeDiagnostic) 3, каждая строит дерево заново — кэша нет, в отличие от queries/symbolTree
Не-AST (38 прямых AbstractDiagnostic + 6 SymbolTree + 5 Metadata) обхода AST нет

Сверх этого — 61 вызов Trees.findAllRuleNodes/getDescendants в 39 файлах, то есть скрытые вложенные обходы внутри visit-методов (TimeoutsInExternalResourcesDiagnostic, UnusedParametersDiagnostic, UsageWriteLogEventDiagnostic, UnreachableCodeDiagnostic). Местами квадратично: findAllRuleNodes(RULE_statement) в цикле по найденным newExpression.

При этом «интересных» узлов мало: 186 переопределений visitXxx на ~50 разных правил, и ~30 правил слушает ровно одна диагностика. Топ по частоте:

Метод Диагностик Метод Диагностик
visitFile 15 visitStatement 5
visitGlobalMethodCall 13 visitComplexIdentifier 5
visitSub 12 visitAssignment / visitIfBranch / visitLValue / visitCallStatement по 4
visitNewExpression 10 visitSubCodeBlock 4
visitFileCodeBlock 9 visitCodeBlock / visitAccessProperty / visitTernaryOperator / visitForEachStatement / visitParamList / visitElsifBranch / visitIfStatement по 3
visitFunction 8
visitFileCodeBlockBeforeSub 6
visitMethodCall / visitProcedure 5

Показательно, что топ-1 visitFile — это вообще не «интересный узел», а точка входа/выхода жизненного цикла. То есть 116 обходчиков ходят по всему дереву ради полусотни типов узлов.

Что мешает наивному слиянию

1. Обрезка поддеревьев — 59 из 186 переопределений (32%)

Не зовут ни super.visitXxx, ни visitChildren. Это не небрежность, а осмысленная экономия:

  • CognitiveComplexityDiagnostic.java:97CyclomaticComplexityDiagnostic.java:90) — метрика уже посчитана, тело метода обходить незачем: visitSubreturn ctx.
  • DuplicateRegionDiagnostic.java:79visitFile целиком работает по symbolTree.getModuleLevelRegions(), AST не обходится вообще.
  • CodeOutOfRegionDiagnostic.java:161,171visitSub/visitFileCodeBlockreturn ctx, фактически обходится только верхний уровень.
  • TimeoutsInExternalResourcesDiagnostic.java:204, UnusedParametersDiagnostic.java:56 — свой обход поддерева через findAllRuleNodes вместо родного, потом return ctx.
  • MethodSizeDiagnostic.java:52,65, NumberOfParamsDiagnostic.java:52, OrderOfParamsDiagnostic.java:46 — простое «дальше неинтересно».

В общем обходе return ctx одного подписчика не может остановить обход для остальных → нужен персональный признак «не звать меня внутри этого поддерева».

2. Порядок «вход-выход» и состояние обхода

Минимум 12 диагностик держат стек/счётчик вокруг обхода:

  • NestedStatementsDiagnosticnestedParents.push/pop в enter/exit пяти правил;
  • EmptyRegionDiagnostic — счётчики в enterEveryRule/exitEveryRule;
  • CreateQueryInCycleDiagnostic.java:148,157enterScope()super.visitProcedure(ctx)leaveScope();
  • MissingTempStorageDeletionDiagnostic.java:83-127 — взаимно обнуляемые currentSub/fileCodeBlock/fileCodeBlockBeforeSub;
  • DuplicatedInsertionIntoCollectionDiagnostic.java:86-96 — состояние блока очищается до спуска (несимметрично);
  • плюс RefOveruse, ServerCallsInFormEvents, QueryNestedFieldsByDot, OneStatementPerLine, AllFunctionPathMustHaveReturn, CodeAfterAsyncCall.

Значит общий обход обязан быть листенером (enter + exit), а не визитором. Существующие обёртки вида «код до super, код после» ложатся на enter/exit один в один.

3. Финализация после обхода

Минимум 7 диагностик выдают замечания только по завершении обхода — им нужен хук «файл закончился»:

  • OneStatementPerLineDiagnostic.java:108statementsPerLine.clear(); super.visitFile(ctx); addDiagnostics();
  • UnreachableCodeDiagnostic.java:125errorRanges.clear(); super...; appendUnreachableCode(...)
  • MissedRequiredParameterDiagnostic.java:65 — после super.visitFile идёт проход по referenceIndex
  • MissingTempStorageDeletionDiagnostic.java:85, RefOveruseDiagnostic.java:101, DuplicateStringLiteralDiagnostic.java:113

Отдельный случай — RedundantAccessToObjectDiagnostic: единственная неабстрактная диагностика, переопределяющая сам getDiagnostics(...), может вернуть пустой список вообще не запуская обход.

4. Потеря параллелизма — главный подвох

Сейчас 116 обходов раскладываются по ядрам. После слияния это один последовательный обход.

  • В analyze / populateContext файлы и так гоняются parallelStream() поверх ForkJoinPool (lsp/AnalyzeProjectOnStart.java:73, context/ServerContext.java:151), а внутри каждого файла ещё ~150 задач в fixed-пул — это перезаписка ядер. Там слияние даст чистый выигрыш.
  • В интерактивном LSP (один файл, важна задержка) выигрыш «116 → 1» частично съедается уходом от многопоточности. Лечится разбиением подписчиков на K непересекающихся групп (см. ниже).

5. Три несводимых корпуса

BSL AST, SDBL-деревья из getQueries() и не-AST-диагностики. Слить можно только внутри корпуса — реалистичная цель не «один обход на всё», а «один обход на корпус».

Предлагаемое направление

Шаг 0. Измерить

Инфраструктура уже есть: aop/MeasuresAspect.java:61 при app.measures.enabled=true даёт время по каждой диагностике ("diagnostic: <Code>"). JMH-бенчмарка на DiagnosticComputer.compute нет — завести в src/jmh/.

Ключевой вопрос, на который нужны цифры: какая доля времени — сам обход, а какая — работа внутри check (regex, обращения к TypeService, JLanguageTool). Если основное сидит в телах, слияние обходов даст проценты, а не разы. Решать по цифрам, а не по структуре.

Шаг 1. Индекс узлов (дёшево, ломает мало что)

Один обход строит ruleIndex → List<ParserRuleContext>, результат кладётся в DocumentContext как Lazy рядом с getQueries(). Диагностика вместо visitNewExpression спрашивает documentContext.nodes(RULE_newExpression).

Образец уже есть в репозитории — diagnostics/platform/PlatformMemberCalls.collect(): один Trees.findAllRuleNodes с множеством rule-индексов + switch по типу узла, без состояния обхода, класс final со статическими методами. Кстати, его сейчас зовут дважды (UnavailableMemberCallDiagnostic:75 и DeprecatedMethodCallDiagnostic:73) без кэша — это надо чинить в любом случае.

Шаг механически закрывает большинство stateless-визиторов и все 61 findAllRuleNodes, не требуя нового протокола. Порядок enter/exit не даёт — для stateful-диагностик не годится.

Шаг 2. Общий обход с подписчиками

Для тех, кому нужен порядок:

  • Диспетчер — один ParseTreeWalker-подобный обход. На каждом узле subscribers[ctx.getRuleIndex()] (массив по индексу правила). Никто не подписан — стоимость нулевая. Именно здесь выигрыш: сегодня каждый из 116 обходчиков платит полный спуск, даже если ему интересен один newExpression.
  • Контракт подписчика: набор интересующих rule-индексов; enterRule(ctx) / exitRule(ctx); onFileStart(documentContext) / onFileEnd().
  • Обрезка: enterRule может сказать «пропусти мне это поддерево» — диспетчер запоминает узел-маркер и не зовёт подписчика до соответствующего exit. Для CognitiveComplexity это ровно сегодняшнее return ctx.
  • Файловый фильтр: onFileStart возвращает «этот файл мне не интересен» → подписчик исключается из обхода целиком. Сегодня такие гейты (CodeOutOfRegionDiagnostic.java:72, TimeoutsInExternalResourcesDiagnostic.java:232, UsingSynchronousCallsDiagnostic.java:131) всё равно платят за обход.
  • Параллелизм: подписчики независимы (каждый пишет в свой DiagnosticStorage), поэтому их можно разбить на K непересекающихся групп и сделать K обходов параллельно. K = 2..4 вместо 116 — и задержка на одиночном файле не проседает.

Шаг 3. То же для SDBL и Expression Tree

  • SDBL — 17 обходов одного и того же набора запросов, при этом там нет ни обрезок, ни стеков (кроме двух листенеров). Самая дешёвая победа.
  • Expression Tree — 3 диагностики строят одно и то же дерево трижды (diagnostics/AbstractExpressionTreeDiagnostic.java:52). Существующий хук onExpressionEnter с решением SKIP/ACCEPT/VISIT_CHILDREN становится персональным решением подписчика.

Порядок миграции

Не «большим взрывом». Контракт BSLDiagnostic.getDiagnostics остаётся; DefaultDiagnosticComputer сабмитит одну задачу-со-обходчик на всех подписавшихся и по-старому — на всех остальных. Старый и новый механизмы сосуществуют.

  1. AbstractFindMethodDiagnostic — 16 диагностик, чистая механика: обе точки (visitGlobalMethodCall/visitMethodCall) сводятся к regex по methodName. Осторожно с MissingTempStorageDeletionDiagnostic.java:127, где checkGlobalMethodCall переопределён как side-effect, а не предикат.
  2. Длинный хвост stateless-визиторов с 1-2 visitXxx.
  3. Stateful со стеками.
  4. SDBL.
  5. Expression Tree.

Проверка эквивалентности

SmokyTest уже гоняет --analyze по всему src/test/resources/diagnostics. К нему добавить дифференциальный прогон: старый путь vs новый на всём корпусе, сравнение множеств замечаний. Порядок внутри одной диагностики сохранится (обход тот же); порядок между диагностиками и сейчас недетерминирован по коду.

Смежные находки — стоит починить независимо от слияния

Дешевле, и, возможно, дадут больше, чем сам обход:

  • ~150 prototype-бинов диагностик создаётся заново на каждый документdiagnostics/infrastructure/DiagnosticsConfiguration.java:83 + DiagnosticBeanPostProcessor с рефлексивным configure() на каждом создании. Плюс вся цепочка фильтров (isEnabled / passedMinimumLSPDiagnosticLevel / inScope / correctModuleType / passedCompatibilityMode) пересчитывается для каждого документа, хотя зависит только от (fileType, moduleType, compatibilityMode, версия конфигурации) — просится кэш.
  • getTokensFromDefaultChannel() и getComments() (context/DocumentContext.java:198-206) — линейный стрим без кэша, дёргается многими диагностиками и DiagnosticIgnoranceComputer.
  • PlatformMemberCalls.collect() вызывается дважды на документ, результат не кэшируется.
  • Вложенная многопоточность: ForkJoinPool по файлам × fixed-пул по диагностикам внутри каждого файла.

Исследование, реализации пока нет. Каждый шаг оптимизации предполагается отдельным PR с JMH/JFR и замерами до/после.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions