>백엔드 개발 >파이썬 튜토리얼 >작업 중인 인터프리터의 메모리 효율성 향상

작업 중인 인터프리터의 메모리 효율성 향상

Susan Sarandon
Susan Sarandon원래의
2024-12-26 13:30:10369검색

Improving memory efficiency in a working interpreter

수명(Lifetime)은 Rust와 인간 경험의 매혹적인 특징입니다. 이것은 기술 블로그이므로 전자에 집중하겠습니다. 나는 Rust에서 데이터를 안전하게 빌리기 위해 수명을 활용하는 느린 채택자였습니다. Rust로 작성된 Python 인터프리터인 Memphis의 트리워크 구현에서는 수명을 거의 활용하지 않고(끊임없이 복제하여) 가능할 때마다 빌림 검사기를 반복적으로 피합니다(또한 끊임없이 내부 가변성을 사용하여).

러스타시안 여러분, 저는 오늘 이 일이 이제 끝났다는 것을 말씀드리려고 이 자리에 섰습니다. 내 입술을 읽어보세요…더 이상 지름길은 없습니다.

좋아요, 현실적으로 봅시다. 무엇이 지름길인지, 무엇이 올바른 길인지는 우선순위와 관점의 문제입니다. 우리 모두는 실수를 저질렀고, 저는 이에 대한 책임을 져야 합니다.

처음으로 Rustc를 설치한 지 6주 만에 통역사를 쓰기 시작했습니다. 왜냐하면 오한이 없기 때문입니다. 그런 장황한 말투와 가식을 제쳐두고, 수명을 생명선으로 사용하여 비대해진 통역사 코드베이스를 개선할 수 있는 방법에 대한 오늘 강의를 시작하겠습니다.

복제된 데이터 식별 및 방지

Rust 수명은 모든 참조가 참조하는 개체보다 오래 지속되지 않도록 컴파일 시간을 보장하는 메커니즘입니다. 이를 통해 C와 C의 "매달린 포인터" 문제를 피할 수 있습니다.

이는 귀하가 이를 활용한다고 가정합니다! 복제는 수명 관리와 관련된 복잡성을 피하고 싶을 때 편리한 해결 방법이지만 메모리 사용량이 증가하고 데이터가 복사될 때마다 약간의 지연이 발생한다는 단점이 있습니다.

수명을 사용하면 Rust에서 소유자와 차용에 대해 더 관용적으로 생각하게 되는데, 저는 그렇게 하고 싶었습니다.

Python 입력 파일의 토큰으로 첫 번째 후보를 선택했습니다. Amtrak에 앉아 있는 동안 ChatGPT 지침에 크게 의존했던 원래 구현에서는 다음 흐름을 사용했습니다.

  1. Python 텍스트를 Builder에 전달합니다
  2. Builder는 입력 스트림을 토큰화하는 Lexer를 생성합니다
  3. 그런 다음 Builder는 자체 복사본을 보관하기 위해 토큰 스트림을 복제하는 파서를 생성합니다
  4. Builder는 해석기를 생성하는 데 사용됩니다. 해석기는 구문 분석기에게 다음 구문 분석 명령문을 반복적으로 요청하고 토큰 스트림이 끝날 때까지 평가합니다.

토큰 스트림 복제의 편리한 측면은 3단계 후에 Lexer를 자유롭게 삭제할 수 있다는 것입니다. Lexer가 토큰을 소유하고 Parser가 토큰을 빌릴 수 있도록 아키텍처를 업데이트하면 이제 Lexer는 계속 유지되어야 합니다. 훨씬 더 오래 살아요. Rust 수명은 이를 보장합니다. 파서가 빌린 토큰에 대한 참조를 보유하고 있는 한 컴파일러는 해당 토큰을 소유한 Lexer가 여전히 존재하도록 보장하여 유효한 참조를 보장합니다.

모든 코드가 항상 그렇듯, 이는 예상했던 것보다 더 큰 변화로 끝났습니다. 그 이유를 알아보겠습니다!

새로운 파서

Lexer에서 토큰을 빌리기 위해 Parser를 업데이트하기 전의 모습은 다음과 같았습니다. 오늘 토론에서 관심 있는 두 가지 필드는 tokens와 current_token입니다. 우리는 Vec 하지만 그것은 분명히 우리의 것입니다(즉, 우리는 그것을 빌린 것이 아닙니다).

pub struct Parser {
    state: Container<State>,
    tokens: Vec<Token>,
    current_token: Token,
    position: usize,
    line_number: usize,
    delimiter_depth: usize,
}

impl Parser {
    pub fn new(tokens: Vec<Token>, state: Container<State>) -> Self {
        let current_token = tokens.first().cloned().unwrap_or(Token::Eof);
        Parser {
            state,
            tokens,
            current_token,
            position: 0,
            line_number: 1,
            delimiter_depth: 0,
        }
    }
}

Lexer에서 토큰을 빌린 후에는 상당히 유사해 보이지만 이제 LIFETIME이 표시됩니다! 수명 'a에 토큰을 연결함으로써 Rust 컴파일러는 토큰 소유자(Lexer)와 토큰 자체가 Parser가 계속 참조하는 동안 삭제되는 것을 허용하지 않습니다. 안전하고 고급스러운 느낌이에요!

static EOF: Token = Token::Eof;

/// A recursive-descent parser which attempts to encode the full Python grammar.
pub struct Parser<'a> {
    state: Container<State>,
    tokens: &'a [Token],
    current_token: &'a Token,
    position: usize,
    line_number: usize,
    delimiter_depth: usize,
}

impl<'a> Parser<'a> {
    pub fn new(tokens: &'a [Token], state: Container<State>) -> Self {
        let current_token = tokens.first().unwrap_or(&EOF);
        Parser {
            state,
            tokens,
            current_token,
            position: 0,
            line_number: 1,
            delimiter_depth: 0,
        }
    }
}

눈에 띌 수 있는 또 다른 작은 차이점은 다음 줄입니다.

static EOF: Token = Token::Eof;

이것은 파서가 "메모리 효율적" 방향으로 움직이면서 고려하기 시작한 작은 최적화입니다. 파서가 텍스트 스트림의 끝에 있는지 확인해야 할 때마다 새 Token::Eof를 인스턴스화하는 대신 새 모델을 사용하면 단일 토큰만 인스턴스화하고 &EOF를 반복적으로 참조할 수 있었습니다.

다시 말하지만, 이는 작은 최적화이지만 메모리에 단 한 번만 존재하는 각 데이터 조각과 모든 소비자가 필요할 때 이를 참조하는 더 큰 사고방식을 말해줍니다. Rust는 이를 권장하고 꼭 손을 잡고 있습니다. 방법입니다.

최적화라고 하면 정말 전후의 메모리 사용량을 벤치마킹했어야 했는데. 제가 하지 않은 일이라 더 이상 드릴 말씀이 없습니다.

앞서 언급했듯이 Lexer와 Parser의 수명을 함께 묶는 것은 내 Builder 패턴에 큰 영향을 미칩니다. 어떤 모습인지 살펴보겠습니다!

새로운 빌더: MemphisContext

위에서 설명한 흐름에서 Parser가 자체 토큰 복사본을 생성하자마자 Lexer가 삭제될 수 있다고 어떻게 언급했는지 기억하시나요? 이는 Python 텍스트 스트림으로 시작하든 Python 파일 경로로 시작하든 상관없이 Lexer, Parser 및 Interpreter 상호 작용 조정을 지원하는 구성 요소로 의도된 내 Builder의 디자인에 의도치 않게 영향을 미쳤습니다.

아래에서 볼 수 있듯이 이 디자인에는 이상적이지 않은 몇 가지 다른 측면이 있습니다.

  1. 통역사를 가져오기 위해 위험한 다운캐스트 메서드를 호출해야 합니다.
  2. 모든 단위 테스트에 파서를 반환한 다음 곧바로interpreter.run(&mut 파서)에 전달하는 것이 괜찮다고 생각한 이유는 무엇입니까?!
fn downcast<T: InterpreterEntrypoint + 'static>(input: T) -> Interpreter {
    let any_ref: &dyn Any = &input as &dyn Any;
    any_ref.downcast_ref::<Interpreter>().unwrap().clone()
}

fn init(text: &str) -> (Parser, Interpreter) {
    let (parser, interpreter) = Builder::new().text(text).build();

    (parser, downcast(interpreter))
}


#[test]
fn function_definition() {
     let input = r#"
def add(x, y):
    return x + y

a = add(2, 3)
"#;
    let (mut parser, mut interpreter) = init(input);

    match interpreter.run(&mut parser) {
        Err(e) => panic!("Interpreter error: {:?}", e),
        Ok(_) => {
            assert_eq!(
                interpreter.state.read("a"),
                Some(ExprResult::Integer(5.store()))
            );
        }
    }
}

다음은 새로운 MemphisContext 인터페이스입니다. 이 메커니즘은 Lexer 수명을 내부적으로 관리하고(Parser를 만족시킬 만큼 오랫동안 참조를 유지하기 위해!) 이 테스트를 실행하는 데 필요한 것만 노출합니다.

pub struct Parser {
    state: Container<State>,
    tokens: Vec<Token>,
    current_token: Token,
    position: usize,
    line_number: usize,
    delimiter_depth: usize,
}

impl Parser {
    pub fn new(tokens: Vec<Token>, state: Container<State>) -> Self {
        let current_token = tokens.first().cloned().unwrap_or(Token::Eof);
        Parser {
            state,
            tokens,
            current_token,
            position: 0,
            line_number: 1,
            delimiter_depth: 0,
        }
    }
}

context.run_and_return_interpreter()는 여전히 약간 투박하며 앞으로 해결해야 할 또 다른 설계 문제를 나타냅니다. 인터프리터를 실행할 때 최종 반환 값만 반환하시겠습니까, 아니면 임의의 값에 액세스할 수 있는 값을 반환하시겠습니까? 기호 테이블에서? 이 방법은 후자의 접근 방식을 선택합니다. 실제로 두 가지를 모두 수행할 수 있는 경우가 있다고 생각하며 이를 허용하도록 API를 계속 조정할 예정입니다.

이 변화로 인해 임의의 Python 코드를 평가하는 능력이 향상되었습니다. 내 WebAssembly 이야기를 기억하신다면 당시에는 크로스체크 TreewalkAdapter를 사용하여 이를 수행해야 했습니다. 이제 Wasm 인터페이스가 훨씬 더 깔끔해졌습니다.

static EOF: Token = Token::Eof;

/// A recursive-descent parser which attempts to encode the full Python grammar.
pub struct Parser<'a> {
    state: Container<State>,
    tokens: &'a [Token],
    current_token: &'a Token,
    position: usize,
    line_number: usize,
    delimiter_depth: usize,
}

impl<'a> Parser<'a> {
    pub fn new(tokens: &'a [Token], state: Container<State>) -> Self {
        let current_token = tokens.first().unwrap_or(&EOF);
        Parser {
            state,
            tokens,
            current_token,
            position: 0,
            line_number: 1,
            delimiter_depth: 0,
        }
    }
}

Context.evaluate_oneshot() 인터페이스는 전체 기호 테이블이 아닌 표현식 결과를 반환합니다. "원샷" 메서드가 컨텍스트에서 한 번만 작동하여 어떤 소비자도 상태 저장 컨텍스트에서 해당 메서드를 사용하지 않도록 하는 더 좋은 방법이 있는지 궁금합니다. 계속 끓이겠습니다!

그만한 가치가 있었나요?

멤피스는 무엇보다 학습 공간이므로 정말 가치가 있었습니다!

Lexer와 Parser 간에 토큰을 공유하는 것 외에도 훨씬 적은 수의 상용구로 Python 코드를 평가할 수 있는 인터페이스를 만들었습니다. 데이터 공유로 인해 복잡성이 가중되었지만 이러한 변경으로 인해 메모리 사용량 감소, 보다 엄격한 수명 관리를 통한 안전성 보장 향상, 유지 관리 및 확장이 더 쉬운 간소화된 API 등 분명한 이점이 있습니다.

저는 이것이 주로 자존감을 유지하기 위한 올바른 접근 방식이라고 믿기로 결정했습니다. 궁극적으로는 소프트웨어와 컴퓨터 공학의 원리를 명확하게 반영하는 코드를 작성하는 것을 목표로 합니다. 이제 멤피스 소스를 오픈하고, 토큰의 단일 소유자를 지정하고, 밤에 푹 잘 수 있습니다!

구독 및 저장 [아무것도 없음]

이런 게시물을 받은편지함으로 직접 받아보려면 여기에서 구독하세요!

다른 곳

저는 소프트웨어 엔지니어를 멘토링하는 것 외에도 자영업과 자폐증을 늦게 진단받은 경험에 대해 글을 씁니다. 코드는 적고 농담 수는 동일합니다.

  • 호수 효과 커피, 1장 - Scratch dot org에서

위 내용은 작업 중인 인터프리터의 메모리 효율성 향상의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.