速度提升 500%! KCL 高效语义模型技术揭秘:实现快速编译与增强 IDE 支持

149 天前
 peefy

什么是 KCL

KCL 是一个 CNCF 基金会托管的面向云原生场景的配置及策略语言,期望通过成熟的编程语言技术和实践来改进对大量繁杂配置比如云原生 Kubernetes 配置场景的编写,致力于构建围绕配置的更好的模块化、扩展性和稳定性,更简单的逻辑编写,以及更简单的自动化和生态工具集成。

KCL 官网: https://kcl-lang.io

什么是 KCL 语义模型

为什么 KCL 需要新的语义模型

我们可以先简略地看一下 KCL 旧语义模型的设计:

旧语义模型可以简单地看作一个大量作用域的集合,其中不同的作用域内存储了作用域之间的父子节点以及内部包含的符号字符串及对应类型,它可以简单地满足编译器对类型检查和代码生成的需求。但涉及到高级工具链(例如 IDE )这种简单的结构就不足以满足需求了。举几个典型的 IDE 查询为例:

仅利用旧语义模型,这就要求 IDE 多次遍历 AST 以及进行多次重复计算,可以简单地分析一下旧语义模型的问题,我们可以发现:

简而言之,旧语义模型无法满足高级工具链的查询需求,大量信息缺失,另一方面,旧语义模型不支持增量编译,这也降低了工具链的用户体验。

新语义模型的主要思路:Map Reduce

Map Reduce 架构的想法是将分析分为相对简单的索引阶段和单独的完整分析阶段。索引的核心约束是它基于每个文件运行,索引器获取单个文件的文本,对其进行解析,并吐出有关该文件的一些数据。索引器无法触及其他文件。完整分析可以读取其他文件,并利用索引中的信息来节省工作量。这听起来太抽象了,所以让我们看一个具体的例子——Java 。在 Java 中,每个文件都以包声明开头。索引器将包的名称与类名连接起来以获得全限定名。它还收集类中声明的方法集、超类和接口的列表等。每个文件的数据被合并到一个索引中,该索引将全限定名( FQN )映射到类。索引的更新成本很低,当文件修改请求到达时,索引中该文件的贡献将被删除,文件的文本将被更改,索引器将在新文本上运行并添加新的贡献。要做的工作量与更改的文件数量成正比,并且与文件总数无关。让我们看看如何使用 FQN 索引来快速提供补全。

// File ./mypackage/Foo.java
package mypackage;

import java.util.*;

public class Foo {
    public static Bar f() {
        return new Bar();
    }
}

// File ./mypackage/Bar.java
package mypackage;

public class Bar {
    public void g() {}
}

// File ./Main.java
import mypackage.Foo;

public class Main {
    public static void main(String[] args) {
        Foo.f().
    }
}

用户刚刚输入了 Foo.f().,我们需要弄清楚接收者表达式的类型是 Bar,并建议 g 作为补全。首先,当文件 Main.java 被修改时,我们在这个单个文件上运行索引器:没有任何变化(文件仍然包含具有静态 main 方法的类 Main),因此我们不需要更新 FQN 索引。接下来,我们需要解析名称 Foo。我们解析文件 Main.java,注意到了 import mypackage.Foo,并且在 FQN 索引中查找 mypackage.Foo 。

在索引当中,我们发现 Foo 有一个静态方法 f, 于是我们也成功解析了调用 f()。索引还存储了 f 的返回类型, 但是请注意,索引中存储的是字符串 "Bar",而不是对类 Bar 的直接引用。这样做的原因在于,Foo.java 中的 import java.util.* 会导致 Bar 可以被推测为 java.util.Bar 或 mypackage.Bar, 索引器并不知道具体是哪一个,因为它只能“看到”文件 Foo.java 的文本。

换句话说,虽然索引确实存储了方法的返回类型,但它以未解析的形式存储它们。下一步是在 Foo.java 的上下文中解析标识符 Bar。这会继续使用 FQN 索引,并且定位到类 mypackage.Bar。于是最后的最后,想要的补全:方法 g 就被我们找到了 完成过程中总共只触及了三个文件。FQN 索引使我们能够完全忽略项目中的所有其他文件。到目前为止所描述的方法的一个问题是从索引解析类型需要大量的工作。例如,如果 Foo.f 被多次调用,这项工作可能会重复。解决办法是添加缓存。名称解析结果会被记忆,因此只需要进行一次解析。任何更改都会导致缓存完全消失——使用索引,重建缓存的成本并不那么高。总结一下,第一种方法的工作原理如下:

  1. 每个文件都被独立且并行地索引,生成一个“存根”—— 指一组可见的顶级声明,具有未解析的类型。
  2. 所有存根都合并到一个索引数据结构中。
  3. 名称解析和类型推断主要是在存根基础上进行的。
  4. 名称解析是惰性的(我们仅在需要时从存根解析类型)并且是记忆的(每种类型仅解析一次)。
  5. 每次更改时缓存都会完全失效
  6. 索引是增量更新的:
    • 如果编辑没有更改文件的存根,则不需要更改索引。
    • 否则,旧索引将被删除,新索引被重新添加

这种方法足够简单并具有出色的性能。大部分工作主要在索引阶段,而这些工作我们可以并行地执行。此架构的两个示例是 IntelliJ 和 Sorbet 。这种方法的主要缺点是它只有在有效时才有效—— 具体而言,并非每种语言都有明确定义的 FQN 概念。但总的来说,设计好模块和名称解析对于语言来说总是好的,具体到目前的情况上,KCL 正好满足这个条件。

新语义模型 Pipeline

新语义模型总体 pipeline 如下:

Resolver 结构

Namer 结构

Advanced Resolver 结构

相较于原语义模型分析流程,新语义模型新增了 namer 和 advanced_resolver 两轮 pass ,从而在不影响原有编译器流程的情况下增强对高级工具链的支持。

新语义模型语义数据库:GlobalState

新语义模型的核心结构是 core::GlobalState,工具链主要通过它来完成与编译器的交互及查询。

/// GlobalState is used to store semantic information of KCL source code
#[derive(Default, Debug, Clone)]
pub struct GlobalState {
    // store all allocated symbols
    symbols: KCLSymbolData,
    // store all allocated scopes
    scopes: ScopeData,
    // store package infomation for name mapping
    packages: PackageDB,
    // store semantic information after analysis
    pub(crate) sema_db: SemanticDB,
}

GlobalState 作为新语义模型的语义数据库被使用,是语义分析后的最终产物,主要包含四个方面的信息:

SymbolData

SymbolData 负责管理符号的分配,存储分配的符号以及相关的语义信息, 在这里我们借用 rust 的 arena 设计来访问相关符号。

#[derive(Default, Debug, Clone)]
pub struct KCLSymbolData {
    pub(crate) values: Arena<ValueSymbol>,
    pub(crate) packages: Arena<PackageSymbol>,
    pub(crate) attributes: Arena<AttributeSymbol>,
    pub(crate) schemas: Arena<SchemaSymbol>,
    pub(crate) type_aliases: Arena<TypeAliasSymbol>,
    pub(crate) unresolved: Arena<UnresolvedSymbol>,
    pub(crate) rules: Arena<RuleSymbol>,

    pub(crate) symbols_info: SymbolDB,
}

在新语义模型中,我们使用 core::SymbolRef 来表示对一个符号的引用,同时也利用 SymbolRef 来访问 SymbolData 中具体的符号信息。

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SymbolRef {
    pub(crate) id: generational_arena::Index,
    pub(crate) kind: SymbolKind,
}

具体而言,SymbolData 会根据 SymbolRef 的 kind,取出不同类型的 Symbol ,并转化为抽象的 trait Symbol

pub type KCLSymbol = dyn Symbol<SymbolData = KCLSymbolData, 
    SemanticInfo = KCLSymbolSemanticInfo>;pub fn get_symbol(&self, id: SymbolRef) -> Option<&KCLSymbol> {
        match id.get_kind() {
            SymbolKind::Schema => self
                .schemas
                .get(id.get_id())
                .map(|symbol| symbol as &KCLSymbol),
            ...
        }
    }
pub trait Symbol {
    type SymbolData;
    type SemanticInfo;
    fn get_sema_info(&self) -> &Self::SemanticInfo;
    fn is_global(&self) -> bool;
    fn get_range(&self) -> Range;
    fn get_owner(&self) -> Option<SymbolRef>;
    fn get_definition(&self) -> Option<SymbolRef>;
    fn get_name(&self) -> String;
    fn get_id(&self) -> Option<SymbolRef>;
    fn get_attribute(&self, ...) -> Option<SymbolRef>;
    fn has_attribute(&self, ...) -> bool;

    fn get_all_attributes(&self, ...) -> Vec<SymbolRef>;

    fn simple_dump(&self) -> String;

    fn full_dump(&self, data: &Self::SymbolData) -> Option<String>;
}

通过这个 trait ,工具链可以轻松地完成符号语义信息以及引用关系的查询。

ScopeData

ScopeData 的设计思路实际上和 SymbolData 类似,存储了不同类型的 Scope 并使用 ScopeRef 访问。

#[derive(Default, Debug, Clone)]
pub struct ScopeData {
    /// map pkgpath to root_scope
    pub(crate) root_map: IndexMap<String, ScopeRef>,
    pub(crate) locals: generational_arena::Arena<LocalSymbolScope>,
    pub(crate) roots: generational_arena::Arena<RootSymbolScope>,
}
pub trait Scope {
    type SymbolData;
    fn get_filename(&self) -> &str;
    fn get_parent(&self) -> Option<ScopeRef>;
    fn get_children(&self) -> Vec<ScopeRef>;

    fn contains_pos(&self, pos: &Position) -> bool;

    fn get_owner(&self) -> Option<SymbolRef>;
    fn look_up_def(&self, ...) -> Option<SymbolRef>;

    fn get_all_defs(&self, ...) -> HashMap<String, SymbolRef>;

    fn dump(&self, scope_data: &ScopeData, 
            symbol_data: &Self::SymbolData) -> Option<String>;
}

SemanticDB

SemanticDB 本质上是语义对象部分语义信息的缓存和整合,它的作用主要是用于加速 GlobalState 内部信息的维护以及查询。

#[derive(Debug, Default, Clone)]
pub struct SemanticDB {
    pub(crate) file_sema_map: IndexMap<String, FileSemanticInfo>,
}

#[derive(Debug, Clone)]
pub struct FileSemanticInfo {
    pub(crate) filename: String,
    pub(crate) symbols: Vec<SymbolRef>,
    pub(crate) scopes: Vec<ScopeRef>,
    pub(crate) symbol_locs: IndexMap<SymbolRef, CachedLocation>,
    pub(crate) local_scope_locs: IndexMap<ScopeRef, CachedRange>,
}

总结

KCL 新语义模型实质上只做了两件事,一是将工具链在应用层做的重复计算下沉至语义层,并设计对应机制简化信息查询,二是将语义分析过程中丢失的信息重新分析并缓存。这么做主要有几个目的:

从实践上也基本达到了上述目的,迁移后的 LSP 相关功能的代码量约下降 60%,增量编译后编译速度约提升 500%, 并且已经更新在 KCL v0.7.0 版本当中,欢迎使用体验。

KCL v0.7.0 重磅发布 - 面向云原生场景更完善的生态模型、语言和工具

其他资源

689 次点击
所在节点    程序员
0 条回复

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/999779

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX