Perfekt SAST. Parser

Eine Reihe von Artikeln beschreibt den optimalen Ansatz für die Implementierung von Tools zur statischen Code-Analyse, die für Spezialisten entwickelt wurden. Ziel der Arbeit sind Ansätze, Modelle und Techniken, um eine maximale Werkzeugleistung zu erzielen und gleichzeitig die Komplexität der Entwicklung und Unterstützung / Änderung des Werkzeugs zu minimieren. Dieser Artikel beschreibt eine Möglichkeit, den Parser zu beschleunigen und den Speicherverbrauch zu reduzieren. Der Artikel ist so strukturiert, dass der Leser das von Terrence Parr verfasste Handbuch liest.

ANTLR in vollen Zügen nutzen


ANTLR funktioniert am besten zwischen etwa 1 KB und 10 KB, größere Dateien werden von dieser Bibliothek nicht mit maximaler Geschwindigkeit analysiert. Auch das Debuggen von Arbeiten an großen Dateien wird fast unmöglich. Der Hauptgrund, warum Sie die Eingabedaten für den Parser innerhalb der angegebenen Grenzen halten müssen, besteht darin, dass Verzweigungsvorhersagealgorithmen für ANTLR den Stapel aktiv verwenden. Sie müssen also auf jeden Fall die Zyklen in der Grammatik entfernen, wenn sich eine Regel durch viele andere Regeln aufruft, einschließlich bestimmter Aktivierungsbedingungen für eine solche Verbindung: Betrachten Sie ein Beispiel:

var f = fun a(b) { b = 10; return b; }

Dieser Code kann mit folgender Grammatik beschrieben werden:

start: stmt* EOF;
stmt: varDecl | funDecl | expr ';' | 'return' expr ';';
varDecl: 'var' id '=' expr;
expr: id ('=' expr)? | number;
funDecl: 'fun' id '(' id* ')' '{' stmt* '}'

Es ist leicht zu bemerken, dass stmt innerhalb der funDecl-Regel aufgerufen wird, die eine Schleife bildet, die in realen Anwendungen dazu führen kann, dass eine 100-KB-Datei 4 GB RAM-Heap für maximale Leistung benötigt, oder sogar die Fähigkeit zu arbeiten, was eine absolut inakzeptable Situation ist .

Eine Möglichkeit, diesen Nachteil zu überwinden, besteht darin, beim Erstellen von Token spezielle Modifikationen hinzuzufügen, nämlich die Faltung von {} Blöcken zu einem Token, wobei diese Token später in einem neuen Parser verarbeitet werden, zu dessen Eingabe kollabierte Token übertragen werden.

Zu diesem Zweck können Sie in ANTLR die Methode org.antlr.v4.runtime.Lexer # nextToken des Lexers überschreiben, mit der Sie die Faltungsfunktion implementieren können:

  override def nextToken(): Token = super.nextToken() match {
      case x: RScanToken if x.getType == foldOpen => buildFoldToken(x)
      case x: Token => x
  }

  def buildFoldToken(start: RScanToken): RScanToken = {
    val (token, list) = createFoldBlockBody(
      mutable.MutableList[RScanToken]()
        ++ List(start.asInstanceOf[RScanToken])
    )
    val result = new RScanToken(path, _tokenFactorySourcePair, foldBlock, Lexer.DEFAULT_TOKEN_CHANNEL, start.getStartIndex, token.getStopIndex)
    result.setLine(start.getLine)
    result.setCharPositionInLine(start.getCharPositionInLine)
    result.setFoldedTokens(list)
    result
  }

  private def createFoldBlockBody(list: mutable.MutableList[RScanToken]): (RScanToken, mutable.MutableList[RScanToken]) = {
    var current: RScanToken = null
    while (current == null || current.getType != Token.EOF && current.getType != foldClose) {
      current = nextToken().asInstanceOf[RScanToken]
      list += current
    }
    if (current.getType == Token.EOF)
      throw new IllegalStateException()
    (current, list)
  }

Die Grammatik sollte ergänzt werden durch:

startJavaScript 
    :   HashBangLine? jsFileBody? EOF
    ;

startFoldBlock
   :   block EOF
   ;

block
    :   LBRACE stmtList? RBRACE
    |   FoldBlock 
//     ,
//     startFoldBlock
    ;

Bei diesem Ansatz wird der Inhalt der Faltung nicht erneut verarbeitet, da der rekursive Algorithmus Token absorbiert und speichert.

Die Verarbeitungsgeschwindigkeit steigt von 2 auf das 10-fache. Der Unterschied macht sich insbesondere bei minimierten JS-Dateien bemerkbar.

Die Auswirkungen der vorgeschlagenen Optimierung auf die ANTLR-Operation am Beispiel mehrerer Javascript-Dateien:
DateiGröße kbVor der Optimierung msNach der Optimierung ms
normalize-and-load-metadata.js1018391372
rewrite-live-reference.js8899528
dom.umd.min.js130438526607
react.pure.umd.min.js139516686495
tsserver.js8151362857117787

Ohne Optimierung wird in einem Thread gearbeitet, bei Optimierung erfolgt die Verarbeitung nach Möglichkeit parallel.

Der Start wurde auf einem Computer mit 64 GB RAM, Intel® Core (TM) i7-9700K-CPU bei 3,60 GHz und OpenJDK-Laufzeitumgebung AdoptOpenJDK (Build 11.0.4 + 11) durchgeführt.

JVM-Startschlüssel: -Xmx4G -XX: + UseG1GC -XX: MaxHeapFreeRatio = 30 -XX: MinHeapFreeRatio = 10
Für die Datei tsserver.js lautet der Schlüssel -Xmx32G, während der Speicherverbrauch 22 GB erreicht hat! Während minimierte Dateien mit einer Optimierung von bis zu 200 KB mit einer Heap-Grenze von 128 MB im Parallelmodus analysiert werden, ohne dass der Garbage Collector den Prozessor belasten muss, maximal 2%!

Profilerstellung


ANTLR unterstützt die Profilerstellung des Parsers / Lexers zur Laufzeit. Um dies zu aktivieren, müssen Sie die folgende Methode aufrufen: org.antlr.v4.runtime.Parser # setProfile, bevor Regeln ausgeführt werden. Dann können Sie alle Informationen erhalten, zum Beispiel auf folgende Weise:

    private def printProfileInfo(parser: AbstractRScanParser, tokenStream: TokenStream): Unit = {
      // do the actual parsing
      val parseInfo = parser.getParseInfo
      val atn = parser.getATN
      for (di <- parseInfo.getDecisionInfo if di.ambiguities.size() > 0) {
        val ds = atn.decisionToState.get(di.decision)
        val ruleName = parser.ruleName(ds.ruleIndex)
        log.debug("Ambiguity in rule '" + ruleName + "' -> {}", di)
        log.debug("=========================")
        log.debug(tokenStream.getText)
        log.debug("=========================")
      }
    }

Ein offensichtliches Plus bei dieser Methode ist die Reduzierung der Datenmenge, die Sie analysieren müssen, da Faltungen durch {} ersetzt werden können und immer in lokaler Faltung arbeiten, was den Prozess der Optimierung der Grammatik erheblich beschleunigt.

Fazit


In diesem Artikel wird eine Methode zur Optimierung der Arbeit formaler Grammatiken vorgeschlagen, die mithilfe der ANTLR-Bibliothek implementiert wurden. Der Ansatz selbst kann jedoch auch auf andere Bibliotheken angewendet werden. Ein Merkmal der Optimierung ist die Verringerung der Anzahl der Übergänge vorwärts, um den Eingabe-Token-Stream zu validieren. Außerdem können Sie große Dateien (mehr als 1 MB) mit Speicherkosten von 512 MB für die Single-Thread-Verarbeitung analysieren, während die mangelnde Optimierung 50-mal mehr Speicher für schnelles Arbeiten mit derselben Grammatik erfordert.

Quellcode auf Github verfügbar. Dieses Projekt soll die Optimierungsarbeit demonstrieren. Das Projekt wird so weit wie möglich vereinfacht. Es wird empfohlen, die Studie in der GrammarTester-Klasse zu starten. Es wurde eine Startkonfiguration dafür vorgenommen. Sie müssen nur den Pfad zum Ordner mit den Eingabedaten registrieren. Achten Sie auf den Schlüssel rscan.tester.cache.dir. Temporäre Dateien werden in diesen Ordner geschrieben, die erfolgreiche Dateianalyse wird in diesem Ordner behoben und der Parser kann sie nicht ein zweites Mal analysieren - praktisch, um Fehler zu beheben. Derzeit wurde die Grammatik an 17.000 Dateien aus node_modules getestet, die für die Kernreaktionsanwendung erstellt wurden. Nur verschachtelte node_modules wurden ignoriert.

Im nächsten Artikel werden Optionen zum Organisieren von Daten und das R-Modell (Reflection) für die optimale Darstellung der Anfangsdaten als Analyseobjekte mit minimalem Speicherverbrauch, die Fähigkeit, Suchalgorithmen für Elemente mit der Komplexität O (1) zu implementieren und in serialisierter Form auf der Festplatte zu speichern, betrachtet alle Objekte sowie Teile des Werkzeugs zum Testen von Einheiten.

Source: https://habr.com/ru/post/undefined/


All Articles