본문으로 건너뛰기

"nn" 태그로 연결된 4개 게시물개의 게시물이 있습니다.

nn-lang tag description

모든 태그 보기

Welcome

· 약 1분
Main Developer

Welcome to nn-lang blog

03. Language server

· 약 4분
Main Developer

SieR-VR blog에서 가져온 글입니다.

이번 글은 언어 서버에 대해 설명하려 한다.

다만 예전 구현은 별로 설명할만한 구석이 없어서 현재(2024-10-03) 기준으로 글을 작성하였다.

또한, 구현 중 참고했던 Typescript Language Server (이하 TSServer)코드도 같이 설명했으니 알아두길 바란다.

2024-10-03 aa93904

큰 틀에서의 구조는 TSServer와 크게 다르지 않은데,

src/connection.ts#23-48

export function createLspConnection(options: LspConnectionOptions) {
const connection = createConnection(ProposedFeatures.all);
const client = new LspClient(connection);
const logger = new LspClientLogger(client, options.showMessageLevel);
const documents = new TextDocuments(TextDocument);

const context: Partial<LspContext> = {
logger,
client,
documents,
showMessageLevel: options.showMessageLevel
}

connection.onDidOpenTextDocument((params) => openTextDocument(params, context as LspContext))
connection.onDidCloseTextDocument((params) => onDidCloseTextDocument(params, context as LspContext))
documents.onDidChangeContent((params) => onDidChangeTextDocument(params, context as LspContext))

connection.onInitialize((params) => initialize(params, context))
connection.onCompletion((params, token) => completion(params, context as LspContext, token))
connection.onHover((params, token) => hover(params, context as LspContext, token))

connection.languages.semanticTokens.on((params) => semanticTokens(params, context as LspContext))

documents.listen(connection);
return connection;
}

TSServer/src/lsp-connection.ts#17-66

export function createLspConnection(options: LspConnectionOptions): lsp.Connection {
const connection = lsp.createConnection(lsp.ProposedFeatures.all);
const lspClient = new LspClientImpl(connection);
const logger = new LspClientLogger(lspClient, options.showMessageLevel);
const server: LspServer = new LspServer({
logger,
lspClient,
});

connection.onInitialize(server.initialize.bind(server));
connection.onInitialized(server.initialized.bind(server));
connection.onDidChangeConfiguration(server.didChangeConfiguration.bind(server));

connection.onDidOpenTextDocument(server.didOpenTextDocument.bind(server));
connection.onDidSaveTextDocument(server.didSaveTextDocument.bind(server));
connection.onDidCloseTextDocument(server.didCloseTextDocument.bind(server));
connection.onDidChangeTextDocument(server.didChangeTextDocument.bind(server));

connection.onCodeAction(server.codeAction.bind(server));
connection.onCodeLens(server.codeLens.bind(server));
connection.onCodeLensResolve(server.codeLensResolve.bind(server));
connection.onCompletion(server.completion.bind(server));
connection.onCompletionResolve(server.completionResolve.bind(server));
connection.onDefinition(server.definition.bind(server));
connection.onImplementation(server.implementation.bind(server));
connection.onTypeDefinition(server.typeDefinition.bind(server));
connection.onDocumentFormatting(server.documentFormatting.bind(server));
connection.onDocumentRangeFormatting(server.documentRangeFormatting.bind(server));
connection.onDocumentHighlight(server.documentHighlight.bind(server));
connection.onDocumentSymbol(server.documentSymbol.bind(server));
connection.onExecuteCommand(server.executeCommand.bind(server));
connection.onHover(server.hover.bind(server));
connection.onReferences(server.references.bind(server));
connection.onRenameRequest(server.rename.bind(server));
connection.onPrepareRename(server.prepareRename.bind(server));
connection.onSelectionRanges(server.selectionRanges.bind(server));
connection.onSignatureHelp(server.signatureHelp.bind(server));
connection.onWorkspaceSymbol(server.workspaceSymbol.bind(server));
connection.onFoldingRanges(server.foldingRanges.bind(server));
connection.languages.onLinkedEditingRange(server.linkedEditingRange.bind(server));
connection.languages.callHierarchy.onPrepare(server.prepareCallHierarchy.bind(server));
connection.languages.callHierarchy.onIncomingCalls(server.callHierarchyIncomingCalls.bind(server));
connection.languages.callHierarchy.onOutgoingCalls(server.callHierarchyOutgoingCalls.bind(server));
connection.languages.inlayHint.on(server.inlayHints.bind(server));
connection.languages.semanticTokens.on(server.semanticTokensFull.bind(server));
connection.languages.semanticTokens.onRange(server.semanticTokensRange.bind(server));
connection.workspace.onWillRenameFiles(server.willRenameFiles.bind(server));

return connection;
}

위쪽 코드가 nn의 lsp 메소드별 라우팅, 아래쪽 코드가 TSServer가 구현하고 있는 lsp 메소드별 라우팅이다.

다만 라우팅 하위 코드를 TSServer에서는 class로 구현하고 있는 반면,

src/features/hover.ts#17

export async function hover(params: TextDocumentPositionParams, context: LspContext, token?: CancellationToken): Promise<Hover | null> {

nn 구현에서는 context 객체를 따로 만들어서 인자로 넣어주고 있다. (큰 이유는 없지만 class 문법의 장황함과 indent가 맘에 안들었다.)

그 외 설명할만한 포인트가 하나 있는데,

TSServer/src/document.ts#445-449

private triggerDiagnostics(delay: number = 200): void {
this.diagnosticDelayer.trigger(() => {
this.sendPendingDiagnostics();
}, delay);
}

diagnostic을 전달하기 전에 일부러 200ms의 딜레이를 준다.

이유는 사용자의 키 스트로크가 진행되는 중에는 diagnostic이 실시간으로 변하지 않게 하기 위함이다.

(이 기능 때문에 TSServer에서 diagnostic을 전달하는 코드가 엄청 복잡해졌는데, 간단히 설명하자면 이미 발생한 요청을 취소하기 위해 최소 2~3개의 클래스를 더 만들어야 했다.)

해당 기능도 구현하기로 마음을 먹어

src/utils/delayer.ts

export class Delayer<T> {
constructor(
public defaultDelay: number,
private timeout: NodeJS.Timeout | null = null,
private completionPromise: Promise<T> | null = null,
private onSuccess: ((value: T) => void) | null = null,
private task: (() => T) | null = null,
)
{
}

public trigger(task: () => T, delay: number = this.defaultDelay): Promise<T> {
this.task = task;

if (delay >= 0) {
this.cancelTimeout();
}

if (!this.completionPromise) {
this.completionPromise = new Promise<T>((resolve) => {
this.onSuccess = resolve;
}).then(() => {
this.completionPromise = null;
const result = this.task!();
this.task = null;
return result;
});
}

if (delay >= 0 || this.timeout === null) {
this.timeout = setTimeout(() => {
this.timeout = null;
this.onSuccess!(null!);
}, delay >= 0 ? delay : this.defaultDelay);
}

return this.completionPromise;
}

public cancelTimeout(): void {
if (this.timeout !== null) {
clearTimeout(this.timeout);
this.timeout = null;
}
}
}

delayer 코드를 구현하고 적용했다.


아무래도 아직 구현할 게 많기 때문에, 나중에 관련해서 글을 또 써야할 것 같다.

다음 주제는 (아마도) 타입 체커 세부 구현일 것 같다.

02. Name Resolver

· 약 3분
Main Developer

SieR-VR blog에서 가져온 글입니다.

2024-09-11 a5e9153

파서와 간단한 코드젠(트랜스폼)을 만들고 그 다음에 만들어야겠다 생각한 건 타입 체커였다.

사실상의 메인 피쳐이고, 가장 구현하는 데 시간이 오래 걸렸다.

src/resolver/types.ts#3-28

export interface FileScope {
path: string;
declarations: DeclarationScope[];
}

export interface DeclarationScope {
file: FileScope;
declaration: string;

sizes: Size[];
values: Value[];
}

export interface Size {
scope: DeclarationScope;
ident: string;

nodes: Set<Node>;
}

export interface Value {
scope: DeclarationScope;
ident: string;

nodes: Set<Node>;
}

Name Resolver에 사용할 타입들의 정의이다.

  • 파일 단위의 Scope를 기록할 FileScope
  • 함수 정의 내에서의 Scope를 기록할 DeclarationScope
  • 이름에 따라 AST 노드들을 묶는 Size, Value

구현은 이 타입 정의에 따라 내부 값들을 채워넣는 게 전부였다.

src/resolver/index.ts#37-86

for (const decl of sourceCode) {
const declScope: DeclarationScope = {
file: fileScope,
declaration: decl.name.value,
sizes: [],
values: []
}

decl.sizeDeclList.decls
.forEach(ident => {
const sizeScope = toSize(declScope, ident);
declScope.sizes.push(sizeScope);
});

decl.argumentList.args
.forEach(arg => {
const valueScope = toValue(declScope, arg.ident);
declScope.values.push(valueScope);
});

const callExpressions = travel(decl.exprs, isCallExpression);
const identExprs = travel(decl.exprs, isIdentifierExpression);

identExprs
.forEach(identExpr => {
const value = findValue(declScope, identExpr.ident.value);

if (value) {
value.nodes.add(identExpr.ident);
} else {
const newValue = toValue(declScope, identExpr.ident);
declScope.values.push(newValue);
}
});

callExpressions
.flatMap(sizeDeclList => sizeDeclList.sizes)
.filter(ident => !!ident)
.filter(ident => typeof ident !== "number")
.forEach(ident => {
const size = findSize(declScope, ident.value);

if (size) {
size.nodes.add(ident);
} else {
const newSize = toSize(declScope, ident);
declScope.sizes.push(newSize);
}
});

fileScope.declarations.push(declScope);
}

처음 구현했던 name revoler 코드이다.

위 함수를 구현하고 느낀 건데, 생각했던 것 만큼 구현 과정이 어렵진 않았다.

  • 일단 필요한 값들과 타입을 잘 정의한 후,
  • travel 함수를 잘 이용하여 목적 노드들을 뽑아오고,
  • 구한 노드에서 적절한 처리를 하여 필요한 값들을 얻어내면 완성

이라는 큰 틀이 잡히기 시작했다.


Monorepo

그 외 큰 변경점이라면, yarn monorepo를 활용해 모듈을 크게 세 개로 분리했다.

파일 구조

  • root
    • packages
      • nn-language (파싱, 기본적인 처리)
      • nn-language-server (언어 서버)
      • nn-type-checker (이름, 타입 체킹)

대략 이런 형태인데, 이 중 언어 서버 구현에 관한 내용을 다음 글에서 풀어볼까 한다.

01. Starting with

· 약 4분
Main Developer

SieR-VR blog에서 가져온 글입니다.

nn이라는 언어를 처음 고안한건 2022년 말~2023년 초 쯤이었다.

다만 그 당시에는 훨씬 범용 프로그래밍 언어적인 성격이 강했는데,

entry(x: Tensor, a: Tensor, b: Tensor) {
let x = MatMul(x, a);
let x = Bias(x, b);

x
}

Rust의 서브셋같은 느낌으로 처음 구현했었다. (Python)


다만 만들다보니 파이썬을 선택한 의미가 많이 퇴색됐고, (프레임워크 호환성을 위해 선택했었다.)

파이썬 자체 문제 (익숙하지 않은 타이핑 방식, 라이브러리의 부실한 타이핑 및 문서)로 인해 사실상 개발이 많이 어려워져 버려두고 있었다. (당시 코드는 여기에서 확인할 수 있다.)

그 사이에 함수형 언어들을 접하면서 문법을 바라보는 시각도 조금 달라졌었다.

올해에는 꼭 만들어보자 하고 다짐했었는데, 올해 초에는 새 직장에도 적응해야 했고, 개인적으로 다시 잡을만한 시간이 안 나서 이제야 개발에 손을 대고 있다.


기존에 파이썬으로 구현한 경험이 있어서 새삼 느끼지만, 역시 Typescript로 구현하는 편이 훨씬 편하고 코드 정리하기도 쉬웠다.

아무튼, 이 개발기는 Typescript로 작성한 코드 기준으로 쓰일 예정이다. 개변 이전의 역사도 가끔 언급될지도 모르겠지만..


2024-09-06 3b0ea58

맨 처음 proof-of-concept는 ohm.js를 이용하여 만들어졌다.

당시 ohmjs 문법 파일은 이런 형태였다.

src/ohm/nn.ohm

SourceCode {
Declaration = identifier SizeDecls? Arguments "=" "|>"? Expression ("|>" Expression)*

Expression = TupleExpression

StringLiteralExpression = string
IdentifierExpression = identifier -- ident
| StringLiteralExpression
CallExpression = identifier SizeType? "(" ListOf<Expression, ","> ")" -- call
| IdentifierExpression
TupleExpression = Expression ( "," Expression ) + -- tuple
| CallExpression

Arguments = "(" ListOf<ArgumentDeclaration, ","> ")"
ArgumentDeclaration = identifier ":" Type

Type = identifier SizeType?
SizeType = "[" ListOf<Size, ","> "]"
Size = PowerSize

SizeDecls = "[" ListOf<identifier, ","> "]"

SimpleSize = number | identifier
ParenSize = "(" Size ")" -- paren
| SimpleSize
AddSize = Size "+" Size -- add
| ParenSize
MultipleSize = Size "*" Size -- mul
| AddSize
PowerSize = Size "^" Size -- pow
| MultipleSize

string = singleQuoteString | doubleQuoteString
singleQuoteString = "'" identifier "'"
doubleQuoteString = "\"" identifier "\""

identifier = identifierName
identifierName = identifierStart identifierPart*
identifierStart = "_" | "$" | letter
identifierPart = identifierStart | digit

number = "1".."9" digit*
}

tree-sitter를 사용하는 지금도 큰 틀에서 벗어나지는 않는다.

test/parse/linear.nn

Linear(x: Tensor) = 
x, Trainable('weight')
|> MatMul(), Trainable('bias')
|> Bias()

당시 작성했었던 Linear 코드이다.

또한 간단하게라도 Python 코드젠을 만들었는데,

위 코드에 사이즈 정보를 더해 컴파일하면

class Linear:
def __init__(self, input, channel):
self.weight = Tensor.zeros(input, channel)
self.bias = Tensor.zeros(channel)

def __call__(self, x: Tensor):
y = MatMul(x, self.weight)
y = Bias(y, self.bias)
return y

와 같이 간단하게 코드젠이 이루어지는 정도까지는 만들었다.

코드젠 코드는 여기에서 읽어볼 수 있다.