Проблема
Каждая 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:97 (и CyclomaticComplexityDiagnostic.java:90) — метрика уже посчитана, тело метода обходить незачем: visitSub → return ctx.
DuplicateRegionDiagnostic.java:79 — visitFile целиком работает по symbolTree.getModuleLevelRegions(), AST не обходится вообще.
CodeOutOfRegionDiagnostic.java:161,171 — visitSub/visitFileCodeBlock → return ctx, фактически обходится только верхний уровень.
TimeoutsInExternalResourcesDiagnostic.java:204, UnusedParametersDiagnostic.java:56 — свой обход поддерева через findAllRuleNodes вместо родного, потом return ctx.
MethodSizeDiagnostic.java:52,65, NumberOfParamsDiagnostic.java:52, OrderOfParamsDiagnostic.java:46 — простое «дальше неинтересно».
В общем обходе return ctx одного подписчика не может остановить обход для остальных → нужен персональный признак «не звать меня внутри этого поддерева».
2. Порядок «вход-выход» и состояние обхода
Минимум 12 диагностик держат стек/счётчик вокруг обхода:
NestedStatementsDiagnostic — nestedParents.push/pop в enter/exit пяти правил;
EmptyRegionDiagnostic — счётчики в enterEveryRule/exitEveryRule;
CreateQueryInCycleDiagnostic.java:148,157 — enterScope() → 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:108 — statementsPerLine.clear(); super.visitFile(ctx); addDiagnostics();
UnreachableCodeDiagnostic.java:125 — errorRanges.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 сабмитит одну задачу-со-обходчик на всех подписавшихся и по-старому — на всех остальных. Старый и новый механизмы сосуществуют.
AbstractFindMethodDiagnostic — 16 диагностик, чистая механика: обе точки (visitGlobalMethodCall/visitMethodCall) сводятся к regex по methodName. Осторожно с MissingTempStorageDeletionDiagnostic.java:127, где checkGlobalMethodCall переопределён как side-effect, а не предикат.
- Длинный хвост stateless-визиторов с 1-2
visitXxx.
- Stateful со стеками.
- SDBL.
- 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 и замерами до/после.
Проблема
Каждая AST-диагностика сегодня — самостоятельный обходчик дерева.
DefaultDiagnosticComputer.compute()(diagnostics/DefaultDiagnosticComputer.java:62) сабмитит каждую диагностику отдельной задачей вdiagnosticComputerExecutor, аAbstractVisitorDiagnostic.getDiagnostics()(diagnostics/AbstractVisitorDiagnostic.java:43) делаетvisitFile(documentContext.getAst())— полный обход AST.Итого на один документ приходится больше сотни полных обходов одного и того же дерева.
Замеры структуры (grep по
diagnostics/, 205 файлов)AbstractVisitorDiagnostic89 +AbstractFindMethodDiagnostic16 + веткиAbstractMagicValue/AbstractExecuteExternalCode/AbstractCommonModuleName+ 4 листенера)getQueries()(15 визиторов + 2 листенера)AbstractExpressionTreeDiagnostic)queries/symbolTreeAbstractDiagnostic+ 6 SymbolTree + 5 Metadata)Сверх этого — 61 вызов
Trees.findAllRuleNodes/getDescendantsв 39 файлах, то есть скрытые вложенные обходы внутри visit-методов (TimeoutsInExternalResourcesDiagnostic,UnusedParametersDiagnostic,UsageWriteLogEventDiagnostic,UnreachableCodeDiagnostic). Местами квадратично:findAllRuleNodes(RULE_statement)в цикле по найденнымnewExpression.При этом «интересных» узлов мало: 186 переопределений
visitXxxна ~50 разных правил, и ~30 правил слушает ровно одна диагностика. Топ по частоте:visitFilevisitStatementvisitGlobalMethodCallvisitComplexIdentifiervisitSubvisitAssignment/visitIfBranch/visitLValue/visitCallStatementvisitNewExpressionvisitSubCodeBlockvisitFileCodeBlockvisitCodeBlock/visitAccessProperty/visitTernaryOperator/visitForEachStatement/visitParamList/visitElsifBranch/visitIfStatementvisitFunctionvisitFileCodeBlockBeforeSubvisitMethodCall/visitProcedureПоказательно, что топ-1
visitFile— это вообще не «интересный узел», а точка входа/выхода жизненного цикла. То есть 116 обходчиков ходят по всему дереву ради полусотни типов узлов.Что мешает наивному слиянию
1. Обрезка поддеревьев — 59 из 186 переопределений (32%)
Не зовут ни
super.visitXxx, ниvisitChildren. Это не небрежность, а осмысленная экономия:CognitiveComplexityDiagnostic.java:97(иCyclomaticComplexityDiagnostic.java:90) — метрика уже посчитана, тело метода обходить незачем:visitSub→return ctx.DuplicateRegionDiagnostic.java:79—visitFileцеликом работает поsymbolTree.getModuleLevelRegions(), AST не обходится вообще.CodeOutOfRegionDiagnostic.java:161,171—visitSub/visitFileCodeBlock→return ctx, фактически обходится только верхний уровень.TimeoutsInExternalResourcesDiagnostic.java:204,UnusedParametersDiagnostic.java:56— свой обход поддерева черезfindAllRuleNodesвместо родного, потомreturn ctx.MethodSizeDiagnostic.java:52,65,NumberOfParamsDiagnostic.java:52,OrderOfParamsDiagnostic.java:46— простое «дальше неинтересно».В общем обходе
return ctxодного подписчика не может остановить обход для остальных → нужен персональный признак «не звать меня внутри этого поддерева».2. Порядок «вход-выход» и состояние обхода
Минимум 12 диагностик держат стек/счётчик вокруг обхода:
NestedStatementsDiagnostic—nestedParents.push/popв enter/exit пяти правил;EmptyRegionDiagnostic— счётчики вenterEveryRule/exitEveryRule;CreateQueryInCycleDiagnostic.java:148,157—enterScope()→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:108—statementsPerLine.clear(); super.visitFile(ctx); addDiagnostics();UnreachableCodeDiagnostic.java:125—errorRanges.clear(); super...; appendUnreachableCode(...)MissedRequiredParameterDiagnostic.java:65— послеsuper.visitFileидёт проход поreferenceIndexMissingTempStorageDeletionDiagnostic.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-пул — это перезаписка ядер. Там слияние даст чистый выигрыш.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.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
diagnostics/AbstractExpressionTreeDiagnostic.java:52). Существующий хукonExpressionEnterс решениемSKIP/ACCEPT/VISIT_CHILDRENстановится персональным решением подписчика.Порядок миграции
Не «большим взрывом». Контракт
BSLDiagnostic.getDiagnosticsостаётся;DefaultDiagnosticComputerсабмитит одну задачу-со-обходчик на всех подписавшихся и по-старому — на всех остальных. Старый и новый механизмы сосуществуют.AbstractFindMethodDiagnostic— 16 диагностик, чистая механика: обе точки (visitGlobalMethodCall/visitMethodCall) сводятся к regex поmethodName. Осторожно сMissingTempStorageDeletionDiagnostic.java:127, гдеcheckGlobalMethodCallпереопределён как side-effect, а не предикат.visitXxx.Проверка эквивалентности
SmokyTestуже гоняет--analyzeпо всемуsrc/test/resources/diagnostics. К нему добавить дифференциальный прогон: старый путь vs новый на всём корпусе, сравнение множеств замечаний. Порядок внутри одной диагностики сохранится (обход тот же); порядок между диагностиками и сейчас недетерминирован по коду.Смежные находки — стоит починить независимо от слияния
Дешевле, и, возможно, дадут больше, чем сам обход:
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()вызывается дважды на документ, результат не кэшируется.Исследование, реализации пока нет. Каждый шаг оптимизации предполагается отдельным PR с JMH/JFR и замерами до/после.