LLMs and Programming in the first days of 2024

Salvatore Sanfilippo

2024년 초, LLM과 프로그래밍

원문은 Salvatore Sanfilippo님이 에 게재했습니다. 이 블로그 구독하기

먼저 이 글은 LLM에 대한 회고가 아니라는 점을 밝혀두고 싶다. 2023년이 인공지능에 있어 특별한 해였다는 건 분명하다. 그걸 굳이 반복하는 건 의미가 없어 보인다. 대신 이 글은 한 프로그래머 개인의 증언이 되고자 한다. ChatGPT가 등장한 이후, 그리고 나중에는 로컬에서 동작하는 LLM을 사용하면서 나는 이 새로운 기술을 아주 폭넓게 활용해 왔다. 목표는 코드를 작성하는 속도를 높이는 것이지만, 그것만이 목적은 아니다. 애쓸 가치가 없는 프로그래밍의 사소한 부분에 정신적 에너지를 낭비하지 않으려는 의도도 있다. 지적으로는 전혀 흥미롭지 않은 특이한 부분의 문서를 찾느라 보낸 수없이 많은 시간, 종종 별다른 이유 없이 지나치게 복잡한 API를 익히려는 노력, 당장 쓸 수 있지만 몇 시간 뒤면 버릴 프로그램을 작성하는 일. 이런 것들은 이제 하고 싶지 않다. 특히 지금처럼 Google이 쓸모 있는 정보를 찾기 위해 뒤져야 하는 스팸의 바다가 되어버린 상황에서는 더욱 그렇다.

한편 나는 결코 프로그래밍 초보가 아니다. 도움 없이도 코드를 작성할 수 있고, 실제로도 자주 그렇게 한다. 시간이 지나면서 나는 점점 더 LLM을 이용해 하이레벨 코드를 작성하게 됐고, 특히 Python에서 그렇고 C에서는 그 정도가 훨씬 덜하다. LLM에 대한 내 개인적인 경험에서 눈에 띄는 점은 언제 LLM을 써야 하고 언제 쓰면 오히려 작업이 느려지는지를 정확히 알게 됐다는 것이다. 또한 LLM이 Wikipedia나 YouTube에 흩어진 온갖 동영상 강의와 비슷하다는 것도 알게 됐다. 의지와 능력, 절제력을 갖춘 사람에게는 도움이 되지만, 뒤처진 사람에게는 이점이 미미하다는 것이다. 적어도 초기에는 이미 앞서 있는 사람에게만 혜택이 돌아갈까 봐 우려된다.

하지만 하나씩 짚어보자.

전지전능한 존재인가, 앵무새인가?

머신러닝의 이번 새로운 혁신과 진보 물결에서 가장 우려스러운 현상 중 하나는 AI 전문가들이 자신의 지식이 제한적이라는 사실을 인정하지 못하는 모습이다. 호모 사피엔스는 신경망을 발명했고, 더 결정적으로는 신경망의 파라미터를 자동으로 최적화하는 알고리즘을 발명했다. 하드웨어는 점점 더 큰 모델을 학습시킬 수 있게 되었고, 처리할 데이터에 대한 통계적 지식(사전 지식)과 수많은 시행착오를 통한 점진적 근사를 통해 다른 것보다 더 잘 동작하는 아키텍처들이 발견되었다. 하지만 결국 신경망은 여전히 매우 불투명하다.

LLM의 이런 창발적인 능력 중 일부를 설명하지 못하는 상황에서 과학자들에게서는 더 신중한 태도를 기대했을 것이다. 그러나 많은 이들이 LLM을 심각하게 과소평가하며, 결국 LLM이란 다소 발전된 마르코프 체인에 불과하고, 고작 학습 데이터에서 본 것을 극히 제한적으로 변주해 앵무새처럼 되뇌는 존재일 뿐이라고 말했다. 그러다 증거를 마주하자 앵무새라는 비유는 거의 보편적으로 철회되었다.

동시에 열광하는 대중 중 상당수는 LLM에 실제로는 존재하지 않는 초자연적인 능력을 부여했다. 안타깝게도 LLM은 고작 학습 과정에서 본 데이터가 이루는 공간 안에서 보간할 수 있을 뿐이다. 그리고 그것만으로도 이미 대단한 일이다. 실제로 그들의 보간 능력은 제한적이다(하지만 여전히 놀랍고, 예상치 못한 수준이다). 오늘날 가장 거대한 LLM이 지금까지 본 모든 코드가 둘러싼 공간 안에서 연속적으로 보간할 수만 있다면 얼마나 좋을까! 진정한 새로움을 만들어내지는 못하더라도 프로그래머의 99%를 대체할 수 있을 것이다. 현실은 언제나 그렇듯 훨씬 더 소박하다. LLM은 분명 그 정확한 형태로 본 적 없는 프로그램을 작성할 수 있으며, 학습 데이터에서 어느 정도 빈도로 등장한 서로 다른 아이디어들을 섞어내는 능력을 보여준다. 동시에 이 능력에는 현재 깊은 한계가 있으며, 미묘한 추론이 필요할 때마다 LLM은 처참하게 실패한다는 점도 분명하다. 그럼에도 LLM은 AI가 시작된 이래 오늘날까지 이룬 가장 위대한 성취를 대표한다. 이는 부정하기 어려워 보인다.

어리석지만 박식한

사실이다. LLM은 고작 기초적인 추론만 가능하고, 그마저도 부정확한 경우가 많으며, 존재하지 않는 사실에 대한 환각으로 점철된 경우가 많다. 하지만 그들은 방대한 지식을 가지고 있다. 프로그래밍 분야에서는 물론 양질의 데이터를 확보할 수 있는 다른 분야에서도 LLM은 많은 것을 아는 백치 천재와 같다. 그런 동료와 페어 프로그래밍을 한다는 건 끔찍한 일일 것이다(나에게 페어 프로그래밍은 일반적인 의미에서도 끔찍하다). 그들은 터무니없는 아이디어를 내놓을 것이고 우리는 우리 생각을 관철시키기 위해 끊임없이 싸워야 할 것이다. 하지만 이 박식한 바보가 우리 마음대로 부릴 수 있고 물어보는 모든 질문에 답해준다면 상황은 달라진다. 현재의 LLM이 우리를 지식의 경로 너머로 데려가 주지는 않겠지만, 우리가 잘 모르는 주제를 다룰 때 종종 완전한 무지 상태에서 혼자 나아갈 만큼 충분히 아는 상태까지 끌어올려 줄 수 있다.

프로그래밍 분야에서라면 그들의 능력은 20~30년 전까지만 해도 별 관심이 없었을지도 모른다. 그때는 프로그래밍 언어 두어 개와 고전적인 알고리즘, 그리고 핵심 라이브러리 열 개 정도만 알면 됐다. 나머지는 스스로 채워 넣어야 했다. 자신의 지능과 전문성, 설계 능력으로 말이다. 그런 요소들을 갖추면 거의 모든 것을 할 수 있는 숙련된 프로그래머였다. 시간이 지나면서 프레임워크와 프로그래밍 언어, 온갖 종류의 라이브러리가 폭발적으로 늘어나는 것을 목격했다. 종종 완전히 불필요하고 부당하게 복잡성이 폭발했지만, 진실은 현실이 그렇다는 것이다. 그리고 그런 맥락에서 모든 것을 아는 바보는 소중한 아군이다.

예를 하나 들어보겠다. 나는 적어도 1년 동안 Keras를 이용해 머신러닝 실험을 진행했다. 그러다 여러 이유로 PyTorch로 갈아탔다. 임베딩이나 잔차 네트워크가 무엇인지는 이미 알고 있었지만, PyTorch 문서를 처음부터 차근차근 공부하고 싶은 생각은 없었다(Keras는 ChatGPT가 존재하지 않을 때 배웠기 때문에 그렇게 공부했었다). LLM 덕분에 Torch를 사용하는 Python 코드를 아주 쉽게 작성할 수 있었다. 만들고 싶은 모델에 대한 명확한 아이디어만 있고 올바른 질문을 던지면 됐다.

예시를 들어보자

“클래스 X의 Y를 하는 메서드가 뭐지?” 같은 쉬운 얘기를 하는 게 아니다. 그 정도라면 LLM에 회의적인 사람들의 의견에 동의하고 싶어질지도 모른다. 더 복잡한 모델들이 할 수 있는 일은 훨씬 더 정교하다. 몇 년 전까지만 해도 순수한 마법이었을 일이다. 나는 GPT-4에게 이렇게 말할 수 있다. 봐, 내가 PyTorch로 구현한 신경망 모델이 이거야. 내 배치는 이렇게 생겼어. 배치를 내보내는 함수가 신경망 입력과 호환되도록 텐서를 리사이징하고 싶고, 이런 특정한 방식으로 표현하고 싶어. 리사이징에 필요한 코드를 보여줄 수 있어? GPT-4가 코드를 작성해 주고, 내가 할 일은 Python CLI에서 텐서가 정말 내가 원하는 차원을 가지고 있고 데이터 레이아웃이 올바른지 테스트하는 것뿐이었다.

다른 예도 있다. 얼마 전 ESP32 기반 장치들을 위한 BLE 클라이언트를 구현해야 했다. 조금 조사해 보니 멀티 플랫폼 블루투스 프로그래밍 바인딩은 거의 모두 쓸모가 없었다. 해결책은 간단했다. macOS 네이티브 API를 이용해 Objective-C로 코드를 작성하는 것이었다. 그래서 나는 두 가지 문제를 동시에 처리해야 했다. 내가 무의미하다고 생각하는 패턴으로 가득한(나는 미니멀리스트인데, 그 종류의 API는 내가 생각하는 ‘좋은 설계’와 정반대 스펙트럼에 있다) Objective-C의 cumbersome한 BLE API를 배우는 일과, 동시에 Objective-C 프로그래밍 자체를 기억해내는 일이었다. Objective-C로 마지막으로 프로그램을 작성한 건 10년 전이었다. 이벤트 루프나 메모리 관리 등 많은 세부 사항을 기억하지 못했다.

최종 결과물은 바로 이 코드다. 그다지 아름답지는 않지만 할 일은 한다. 나는 이 코드를 극히 짧은 시간 안에 작성했다. 그렇지 않았다면 불가능했을 것이다.

https://github.com/antirez/freakwan/blob/main/osx-bte-cli/SerialBTE.m

이 코드는 대부분 ChatGPT에 내가 하고 싶지만 제대로 하는 방법을 몰라 제대로 동작하지 않던 것들을 그대로 복사해 붙여 넣고, LLM에게 문제가 무엇이고 어떻게 해결해야 하는지 설명을 들으며 작성됐다. LLM이 그 코드의 많은 부분을 직접 작성한 건 아니지만, 작성을 상당히 가속화한 것도 사실이다. ChatGPT 없이도 할 수 있었을까? 물론 가능했다. 하지만 더 오래 걸렸을 거라는 사실보다 더 흥미로운 점은 애초에 시도조차 하지 않았을 거라는 사실이다. 그럴 가치가 없었기 때문이다. 이 점이 결정적이다. 내 프로젝트에 부차적인 그런 프로그램을 작성하는 데 드는 노력 대비 이득의 비율이 맞지 않았을 것이다. 게다가 이 작업은 프로그램 자체보다 훨씬 더 유용한 부수적인 효과도 가져왔다. 그 프로젝트를 위해 나는 라인 편집용 라이브러리 중 하나인 linenoise를 멀티플렉싱 방식으로 동작하도록 수정했다.

또 다른 예는 코드 작성보다는 데이터 해석에 관한 것이다. 나는 온라인에서 찾은 합성곱 신경망을 이용해 Python 스크립트를 구성하고 싶었는데, 문서가 상당히 부실했다. 그 네트워크는 ONNX 형식이라는 장점이 있어 입력과 출력 목록, 그리고 부여된 이름을 쉽게 추출할 수 있었다. 그 convnet에 대해 내가 아는 건 단 하나였다. 이미지 안의 특정 피처를 탐지한다는 것이다. 입력 이미지의 형식과 크기를 몰랐고, 특히 네트워크 출력은 내가 상상했던 것보다 훨씬 복잡했다(나는 이진 분류기라고 생각했다. 관찰된 이미지가 정상인지 문제가 있는지. 출력이 두 개일 거라 생각했는데 수백 개였다). 나는 ONNX 네트워크 메타데이터 출력을 ChatGPT에 복사해 붙여 넣는 것부터 시작했다. 내가 네트워크에 대해 아는 얼마 안 되는 정보를 어시스턴트에게 설명한다. ChatGPT는 입력이 어떻게 구성되어 있는지, 그리고 출력이 아마도 잠재적 결함에 해당하는 이미지 영역을 나타내는 정규화된 박스와, 그 결함들의 가능성을 나타내는 다른 출력일 것이라고 가설을 세운다. 몇 분간의 대화를 주고받은 끝에 나는 네트워크 추론이 가능한 Python 스크립트와 시작 이미지를 입력에 적합한 텐서로 변환하는 데 필요한 코드 등을 갖게 되었다. 그 세션에서 인상적이었던 점은 ChatGPT가 테스트 이미지의 원시 출력 값(기본적으로 logits)을 관찰한 뒤에야 비로소 네트워크가 어떻게 동작하는지 “이해”했다는 것이다. 일련의 부동소수점 수들이 정확한 출력 세부 사항과 정규화 방식, 박스가 중앙 기준인지 좌상단 모서리 기준인지 등을 식별할 맥락을 제공했다.

일회용 프로그램

위에 서술한 것과 같은 사례를 수십 가지 더 문서화할 수도 있다. 의미가 없을 것이다. 다소 비슷한 방식으로 반복되는 같은 이야기이기 때문이다. 나는 문제가 있고, LLM이 헛소리를 하고 있는지 *검증할 수 있는* 무언가를 빠르게 알아야 한다. 음, 그런 경우 나는 지식에 대한 필요를 가속하기 위해 LLM을 사용한다.

하지만 LLM이 모든 코드를 작성하도록 두는 다른 경우도 있다. 예를 들어 다소 일회용인 프로그램을 작성해야 할 때마다 그렇다. 바로 이런 프로그램처럼 말이다.

https://github.com/antirez/simple-language-model/blob/main/plot.py

작은 신경망의 학습 과정에서 손실 곡선을 시각화할 필요가 있었다. 나는 학습 중 PyTorch 프로그램이 생성한 CSV 파일의 형식을 GPT-4에게 보여준 뒤, 커맨드라인에 여러 CSV 파일을 지정하면 더 이상 같은 실험의 학습 및 검증 손실 곡선을 보여주는 게 아니라 서로 다른 실험들의 검증 손실 곡선을 비교해 보여달라고 요청했다. 위에 있는 것이 GPT-4가 생성한 결과다. 총 30초가 걸렸다.

비슷하게 AirBnB CSV 리포트를 읽어 내 아파트들을 월과 연도별로 그룹화하는 프로그램이 필요했다. 그런 다음 청소 비용과 예약당 숙박 일수를 고려해 연중 월별 평균 임대 가격에 대한 통계를 내는 프로그램이었다. 이 프로그램은 내게 매우 유용하다. 동시에 작성하는 일은 지루하기 짝이 없다. 흥미로운 점이 하나도 없다. 그래서 CSV 파일의 일부를 잘라 GPT-4에 복사해 붙여 넣었다. 해결해야 할 문제가 무엇인지 LLM에게 설명했다. 프로그램은 첫 시도에 동작했다. 아래에 전문을 보여준다.

import pandas as pd
pd.set_option('display.max_rows', None)
df = pd.read_csv('listings.csv')
reservations = df[df['Type'] == 'Reservation']
reservations['Start Date'] = pd.to_datetime(reservations['Start Date'])
reservations['Year'] = reservations['Start Date'].dt.year
reservations['Month'] = reservations['Start Date'].dt.month
reservations['Nightly Rate'] = (reservations['Amount'] - reservations['Cleaning Fee']) / reservations['Nights']
all_listings = reservations['Listing'].unique()
all_years = reservations['Year'].unique()
all_months = range(1, 13)
index = pd.MultiIndex.from_product([all_listings, all_years, all_months], names=['Listing', 'Year', 'Month'])
all_data = pd.DataFrame(index=index).reset_index()
merged_data = pd.merge(all_data, reservations, on=['Listing', 'Year', 'Month'], how='left')
average_nightly_rates = merged_data.groupby(['Listing', 'Year', 'Month'])['Nightly Rate'].mean().fillna(0)
print(average_nightly_rates)

그렇지 않으면 꽤 흩어지고 무질서했던 데이터를 정확히 어떻게 그룹화해야 하는지 이해하려면 약간의 간단한 추론이 필요하다. LLM이 지금까지 본 언어와 근사한 무언가를 단순히 되뇌어 해결할 수 있는 문제가 아니다. GPT-4는 학습 과정에서 분명 비슷한 프로그램들을 관찰했을 것이지만, 내가 요구한 정확한 방식으로, 그리고 그 특정 형식의 CSV 파일에서 무언가를 그룹화해야 했던 프로그램은 거의 확실히 본 적이 없을 것이다. 이는 (내 생각에) LLM이 학습 데이터에 있는 서로 다른 프로그램들이 윤곽을 이루는 공간 안에서 어느 정도 보간할 수 있음을 보여준다.

이런 유형의 프로그램을 작성하는 것은 내 생각에 내 시간을 현명하지 못하게 쓰는 일일 것이다. LLM이 대신 해줄 수 있다는 사실은 확실히 코드를 생산하는 내 능력을 꽤 직접적인 방식으로 가속한다. 중요한 일에 집중할 수 있게 해준다.

일이 뜻대로 되지 않을 때: 시스템 프로그래밍

LLM과 프로그래밍으로 성공을 거뒀음에도 불구하고, C로 프로그램을 작성할 때는 거의 항상 LLM을 더 편리한 형태의 문서로만 사용하게 된다. 나는 시스템 프로그래밍 전문가이고, 그런 맥락에서 LLM은 복잡한 추론이 가능할 때만 유용할 것이다. 그런데 그런 시나리오에서는 거의 항상 실패한다.

이 실험의 프롬프트부터 시작해 보자.

좋고, 작지만 동작하는 C로 된 블룸 필터 구현의 요구사항을 개괄하라. 해시 함수의 중요한 측면에 초점을 맞춰라. 그런 다음 양질의 C로 작성하라.

또한 예제 구현이 10만 개의 원소를 저장하도록 크기가 정해져야 하며, 오탐 확률은 최대 5%여야 한다는 점을 고려하라. 추가되는 원소는 null로 끝나는 문자열이다.

출력은 다음과 같다.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>

#define N 100000  // Number of elements
#define P 0.05    // False positive probability
#define M (int)(-(N * log(P)) / (log(2) * log(2)))  // Size of bit array
#define K (int)(M / N * log(2))  // Number of hash functions

unsigned char bitArray[M / 8 + 1];

void setBit(int n) {
    bitArray[n / 8] |= 1 << (n % 8);
}

int getBit(int n) {
    return bitArray[n / 8] & (1 << (n % 8));
}

// Basic hash functions (for demonstration purposes)
int hash1(char *str) {
    int hash = 5381;
    int c;
    while ((c = *str++))
        hash = ((hash << 5) + hash) + c;
    return abs(hash % M);
}

int hash2(char *str) {
    unsigned long hash = 0;
    int c;
    while ((c = *str++))
        hash = c + (hash << 6) + (hash << 16) - hash;
    return abs(hash % M);
}

void add(char *str) {
    setBit(hash1(str));
    setBit(hash2(str));
    // Add more hash functions if needed
}

int is_in(char *str) {
    return getBit(hash1(str)) && getBit(hash2(str));
    // Check more hash functions if used
}

int main() {
    char *testStr = "testString";
    add(testStr);

    if (is_in(testStr)) {
        printf("%s is probably in the set.\n", testStr);
    } else {
        printf("%s is definitely not in the set.\n", testStr);
    }

    return 0;
}

GPT-4가 생성한 구현은 그다지 훌륭하지 않다. 블룸 필터는 꽤 널리 퍼져 있다. 그렇게 이국적인 자료구조도 아니다. 하지만 제대로 된 블룸 필터를 작성하려면 더 큰 추상화 능력이 필요하다는 건 분명하다. 예를 들어 같은 문자열을 N번 효과적으로 해시하고, 그 해시들이 충분히 비상관화되어 있음을 확신하는 방법에 대한 문제가 있다.

하지만 GPT-4에게 해시 함수가 N개의 비상관화된 출력을 생성하도록 명시적으로 수정해 달라고 요청하면, 제시하는 해결책은 상당히 더 수용 가능하다.

unsigned int hash(const char* str, unsigned int hash_id) {
    unsigned int hash = 5381;
    int c;
    while ((c = *str++)) {
        hash = ((hash << 5) + hash) + c + hash_id;
    }
    return hash % BF_SIZE;
}

이 아이디어를 스스로 떠올렸다면 블룸 필터를 다르게 작성했을 것이고, 단일 해시 함수를 이용해 한 번에 K개의 비트를 설정했을 것이다.

GPT-4는 고립된 상태에서는 적절하고 더 일반적인 해시 함수를 작성하는 작업을 수행할 수 있었다. 하지만 블룸 필터를 작성하는 더 큰 프로젝트에서는 충분한 추론 능력을 보여주지 못하고, 대신 두 개의 서로 다르지만 유사한 해시 함수를 뱉어냈다. 이는 블룸 필터에 대한 조잡하게 작성된 예제 글에서 기대할 만한 모습이다.

거대 언어 모델의 현재의 빈약한 추론 능력에, 해당 주제에 대한 자료의 부족 가능성, 그리고 더 나쁜 경우 잠재적으로 많은 저품질 자료가 결합되어 이런 초라한 결과를 낳는다. 하지만 이는 고립된 사례가 아니다. 알고리즘 또는 시스템 프로그래밍 영역에서 LLM을 사용하려고 시도한 몇 번의 경험에서 결과는 극도로 빈약했으며, 기대되는 추론 능력이 Python 프로그래밍 같은 맥락에서 LLM이 보여주는 수준을 넘지 않는데도 그랬다.

동시에 GPT-4는 (별도의 세션에서) 자신이 생성한 함수를 디컴파일할 수 있고, 그 목적도 정확히 이해한다.

https://chat.openai.com/share/f54b64d7-d19a-4be4-8f10-08553aa812f1

그러므로 시스템 프로그래밍 맥락에서 쓸모가 없다고 말하는 것은 확실히 부정확하다.

흥미롭고 거의 예상했던 대로인 점은, 이런 경우 더 작은 모델과 더 큰 모델 사이의 차이가 실제로 상당하다는 것이다. 두 개의 로컬 모델이 어떻게 수행하는지 보자.

예를 들어 Mistral의 MoE인 Mixtral은 같은 질문에 다음과 같이 답했다.

unsigned int hash(const char* str, unsigned int hash_id) {
    unsigned int hash = 5381;
    int c;
    while ((c = *str++)) {
        hash = ((hash << 5) + hash) + c;
    }
    return hash + hash_id;
}

분명히 마지막에 `hash_id`를 더하는 것은 극히 빈약한 해결책이다. Mixtral은 많은 용도에서 훌륭한 모델이지만, 이미 빈약한 LLM의 추론 능력에 관해서는 지금까지의 규칙이 확실히 ‘클수록 좋다’이다.

이건 로컬 LLM인 deepseek-coder와의 대화다. 더 높은 정밀도로 모델을 사용할 만큼 RAM이 충분하지 않아 4비트로 양자화했다. 그럼에도 340억 파라미터 규모이다 보니 이 문제에 대한 추론 능력은 꽤 발전된 것처럼 보인다. 다음은 대화 로그다(llama.cpp로 수행됨).

I have a hash function that should output N distinct hashes for the same data. However, I'm seeing a very poor distribution of the produced hashes as I change the value of hash_id:

unsigned int hash(const char* str, unsigned int hash_id) {
    unsigned int hash = 5381;
    int c;
    while ((c = *str++)) {
        hash = ((hash << 5) + hash) + c;
    }
    return hash + hash_id;
}

Maybe the salting strategy I'm using is poor? How could I change it?

[DeepSeek Coder 답변]

구현의 가능한 문제 중 하나는 해시의 마지막에 단순히 `hash_id`를 더하고 있다는 점이다. 이는 특히 `hash_id`가 너무 크거나 균등하게 분포되지 않은 경우 좋지 않은 분포로 이어질 수 있다.

비트 연산을 사용해 `hash_id`를 섞는 것을 고려해 보라. XOR를 사용한 예시는 다음과 같다.

unsigned int hash(const char* str, unsigned int hash_id) {
    unsigned int hash = 5381;
    int c;
    while ((c = *str++)) {
        hash = ((hash << 5) + hash) ^ c + hash_id; // Using XOR to mix in the hash_id
     }
    return (hash ^ hash_id); // Mixing the hash_id one more time at the end
}

This will spread `hash_id` throughout your hashes better than simply adding it, which can lead to less uniform distribution. [Snipped… the actual reply contained more informative stuff]

내 MacBook M1 Max에서 동작하는 모델 치고는 나쁘지 않다. 합에 XOR도 섞었다. 이 경우 내가 해결해야 할 문제에 대한 단서를 제공해 모델에 도움이 되긴 했지만, 진짜 문제의 원인이 무엇인지 식별하고 유효한 대안을 제시한 것은 모델이었다. 위와 같은 수준의 결과는 어떤 책이나 문서, Google 검색으로도 얻을 수 없다. 그리고 원시적이든, 보간의 결과든, 어떻게 받아들이든 모델은 어떤 형태의 추론을 수행한 것이다. 추론이라는 것이 이 특정한 경우에 문제의 원인과 잠재적 해결책을 식별하는 것을 의미한다면 말이다. LLM에 대해 어떻게 생각하든 LLM이 프로그래머에게 도움이 되지 않는다고 말하는 것은 극히 경솔한 일이다.

동시에, 지난 몇 달간의 내 경험에 따르면 시스템 프로그래밍에서는 이미 숙련된 프로그래머라면 LLM이 거의 절대 수용 가능한 해결책을 제공하지 않는다. 다른 실제 사례를 보여주겠다. 나의 현재 프로젝트인 ggufflib는 llama.cpp가 양자화된 모델을 로드하는 형식인 GGUF 형식 파일을 읽고 쓰는 라이브러리를 작성하는 것을 포함한다. 처음에는 양자화 인코딩이 어떻게 동작하는지 이해하기 위해(속도상의 이유로 각 quant의 비트가 교묘한 방식으로 저장된다) ChatGPT를 사용해 보려 했지만, 결국 llama.cpp 코드를 리버스 엔지니어링하는 편이 훨씬 빨랐다. 시스템 프로그래머를 제대로 보조할 수 있는 LLM이라면 데이터 인코딩 ‘struct’ 선언과 디코딩 함수를 보고 데이터 형식 문서를 재구성할 수 있어야 한다. llama.cpp의 함수들은 GPT-4의 컨텍스트에 완전히 들어갈 만큼 작았지만, 출력은 완전히 쓸모가 없었다. 이런 경우 예전 방식대로 한다. 종이와 펜을 들고 코드를 읽으며 디코더가 추출하는 비트가 어디에 기록되는지 보는 것이다.

위 사용 사례를 더 잘 설명하겠다. 원한다면 직접 시도해 볼 수 있도록. llama.cpp 구현에서 가져온 이 구조체가 있다.

// 6-bit quantization
// weight is represented as x = a * q
// 16 blocks of 16 elements each
// Effectively 6.5625 bits per weight
typedef struct {
    uint8_t ql[QK_K/2];      // quants, lower 4 bits
    uint8_t qh[QK_K/4];      // quants, upper 2 bits
    int8_t  scales[QK_K/16]; // scales, quantized with 8 bits
    ggml_fp16_t d;           // super-block scale
} block_q6_K;

그리고 역양자화를 수행하는 데 사용되는 이 함수가 있다.

void dequantize_row_q6_K(const block_q6_K * restrict x, float * restrict y, int k) {
    assert(k % QK_K == 0);
    const int nb = k / QK_K;

    for (int i = 0; i < nb; i++) {

        const float d = GGML_FP16_TO_FP32(x[i].d);

        const uint8_t * restrict ql = x[i].ql;
        const uint8_t * restrict qh = x[i].qh;
        const int8_t  * restrict sc = x[i].scales;
        for (int n = 0; n < QK_K; n += 128) {
            for (int l = 0; l < 32; ++l) {
                int is = l/16;
                const int8_t q1 = (int8_t)((ql[l +  0] & 0xF) | (((qh[l] >> 0) & 3) << 4)) - 32;
                const int8_t q2 = (int8_t)((ql[l + 32] & 0xF) | (((qh[l] >> 2) & 3) << 4)) - 32;
                const int8_t q3 = (int8_t)((ql[l +  0]  >> 4) | (((qh[l] >> 4) & 3) << 4)) - 32;
                const int8_t q4 = (int8_t)((ql[l + 32]  >> 4) | (((qh[l] >> 6) & 3) << 4)) - 32;
                y[l +  0] = d * sc[is + 0] * q1;
                y[l + 32] = d * sc[is + 2] * q2;
                y[l + 64] = d * sc[is + 4] * q3;
                y[l + 96] = d * sc[is + 6] * q4;
            }
            y  += 128;
            ql += 64;
            qh += 32;
            sc += 8;
        }
    }
}

GPT-4에게 사용된 형식의 개요를 작성해 달라고 요청하면, “ql”의 하위/상위 4비트에 가중치 위치에 따라 블록이 어떻게 저장되는지 명확한 설명을 제공하는 데 어려움을 겪는다. 이 블로그 포스트를 위해 더 간단한 함수로 데이터가 어떻게 저장되는지 보여주는 함수를 작성해 달라고도 요청해 보았다(아마도 말로는 설명할 수 없지만 코드로는 할 수 있을지도 모른다). 생성된 함수는 여러 면에서 망가져 있다. 인덱스가 잘못되었고, 6비트 -> 8비트 부호 확장이 잘못되었다(그냥 uint8_t로 캐스팅할 뿐이다) 등등이다.

참고로, 내가 결국 직접 작성한 코드는 다음과 같다.

    } else if (tensor->type == GGUF_TYPE_Q6_K) {
        uint8_t *block = (uint8_t*)tensor->weights_data;
        uint64_t i = 0; // i-th weight to dequantize.
        while(i < tensor->num_weights) {
            float super_scale = from_half(*((uint16_t*)(block+128+64+16)));
            uint8_t *L = block;
            uint8_t *H = block+128;
            int8_t *scales = (int8_t*)block+128+64;
            for (int cluster = 0; cluster < 2; cluster++) {
                for (uint64_t j = 0; j < 128; j++) {
                    f[i] = (super_scale * scales[j/16]) *
                           ((int8_t)
                            ((((L[j%64] >> (j/64*4)) & 0xF) |
                             (((H[j%32] >> (j/32*2)) & 3) << 4)))-32);
                    i++;
                    if (i == tensor->num_weights) return f;
                }
                L += 64;
                H += 32;
                scales += 8;
            }
            block += 128+64+16+2; // Go to the next block.
        }
   }

위의 함수에서 나는 이 코드의 실제 기여였던 부분을 제거했다. llama.cpp Q6_K 인코딩에 사용된 정확한 형식을 문서화한 긴 주석들이다. GPT가 이를 대신해 줄 수 있다면 엄청나게 유용할 것이고, 나는 그저 약간의 스케일링만으로, 어떤 돌파구 없이도 도달할 수 있는 종류의 작업이기 때문에 머지않아 가능해질 것이라 확신한다.

관점을 정리하며

유감스럽게도 말해야겠지만 사실이다. 오늘날 프로그래밍의 대부분은 같은 것들을 약간 다른 형태로 되풀이하는 것으로 이루어진다. 높은 수준의 추론이 요구되지 않는다. LLM은 이런 일을 꽤 잘하지만, 컨텍스트의 최대 크기에 여전히 강하게 제한된다. 이는 정말 프로그래머들로 하여금 생각하게 해야 한다. 이런 종류의 프로그램을 작성하는 것이 가치가 있을까? 물론 돈을 받고, 꽤 잘 받지만, LLM이 그 일부를 할 수 있다면 5년이나 10년 뒤에는 그리 좋은 자리가 아닐지도 모른다.

그리고 LLM은 어느 정도 추론 능력을 가지고 있는가, 아니면 전부 허세일 뿐인가? 때로는 기호학자들이 말하듯 ‘기표’가 실제로는 존재하지 않는 의미가 있는 듯한 인상을 주기 때문에 추론하는 것처럼 보일 뿐일지도 모른다. LLM을 충분히 다뤄본 사람들은 그 한계를 인정하면서도 확실히 그렇지 않다는 것을 안다. 그들이 이전에 본 것을 혼합하는 능력은 단어를 무작위로 되뇌는 수준을 훨씬 넘어선다. 학습이 대부분 사전 학습 중에 다음 토큰을 예측하는 과정에서 이루어졌다고 해도, 이 목표는 모델이 어떤 형태의 추상적 모델을 만들도록 강제한다. 이 모델은 빈약하고 듬성듬성하며 불완전하지만, 우리가 관찰하는 것을 관찰한다면 반드시 존재해야 한다. 우리의 수학적 확신이 의심스럽고 최고의 전문가들조차 종종 대립하는 입장이라면, 자신의 눈으로 직접 본 것을 믿는 것이 현명한 접근처럼 보인다.

마지막으로, 오늘날 프로그래밍에 LLM을 사용하지 않는 것이 무슨 의미가 있을까? LLM에게 올바른 질문을 던지는 것은 근본적인 기술이다. 연습이 적을수록 AI 덕분에 자신의 작업을 개선할 수 있는 능력도 줄어들 것이다. 그리고 문제에 대한 서술 능력을 개발하는 것은 다른 사람과 대화할 때도 유용하다. 우리가 말하고자 하는 바를 이해하지 못하는 건 LLM만이 아니다. 제대로 소통하지 못하는 것은 큰 제약이며, 많은 프로그래머들이 특정 분야에서는 매우 뛰어나면서도 소통은 매우 서툴다. 그리고 이제 Google은 쓸모가 없어졌다. LLM을 최소한 압축된 형태의 문서로 사용하는 것조차 좋은 생각이다. 나는 계속해서 LLM을 광범위하게 사용할 것이다. 나는 난해한 통신 프로토콜의 세부 사항이나 자신이 얼마나 뛰어난지 보여주고 싶어 하는 누군가가 작성한 라이브러리의 복잡한 메서드를 배우는 것을 결코 좋아하지 않았다. 내게는 ‘쓰레기 지식’처럼 느껴진다. LLM은 매일 점점 더 그런 것들로부터 나를 구해준다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글