LLMs and Programming in the first days of 2024

Salvatore Sanfilippo

2024년 초, LLM과 프로그래밍

먼저 이 글은 LLM을 회고하려는 목적으로 쓴 글이 아니라는 점을 밝히고 싶습니다. 2023년이 인공지능 분야에 특별한 해였다는 것은 너무나 분명해서 굳이 되풀이할 필요조차 없어 보입니다. 대신 이 글은 한 프로그래머 개인의 증언을 담고자 합니다. ChatGPT가 등장한 이후, 그리고 나중에는 로컬에서 동작하는 LLM을 사용하면서 저는 이 새로운 기술을 매우 폭넓게 활용해 왔습니다. 목적은 코드를 더 빨리 작성하는 것이지만, 그것만이 전부는 아닙니다. 애쓸 가치가 없는 프로그래밍의 세부 사항에 정신적 에너지를 낭비하지 않겠다는 의도도 있습니다. 지적 흥미도 없는 특이한 기능의 문서를 찾아 헤매며 보낸 수많은 시간, 종종 별다른 이유 없이 지나치게 복잡한 API를 익히려는 노력, 몇 시간 쓰고 버릴 일회용 프로그램을 작성하는 일까지. 이런 것들은 이제 더 이상 하고 싶지 않습니다. 특히 지금처럼 구글이 유용한 정보 몇 개를 건지려고 헤쳐 나가야 하는 스팸의 바다가 되어버린 상황에서는 더욱 그렇습니다.

한편 저는 프로그래밍 초보가 결코 아닙니다. 도움 없이도 코드를 작성할 수 있고 실제로도 자주 그렇게 합니다. 시간이 지나면서 저는 LLM을 점점 더 활용해 높은 수준의 코드를 작성해 왔는데, 특히 Python에서 그렇고 C에서는 훨씬 덜합니다. LLM에 대한 제 개인적인 경험에서 가장 인상적인 점은 언제 LLM을 쓰는 것이 좋고 언제 쓰면 오히려 발목을 잡는지를 정확히 알게 되었다는 것입니다. 또한 LLM이 위키백과나 유튜브에 흩어진 수많은 동영상 강의와 비슷하다는 점도 깨달았습니다. 의지와 능력, 절제력을 갖춘 사람에게는 도움이 되지만 이미 뒤처진 사람에게는 도움이 거의 되지 않습니다. 적어도 초기에는 이미 앞서 있는 사람에게만 이득이 될까 두렵습니다.

하지만 차근차근 살펴보겠습니다.

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

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

LLM의 어떤 창발적 능력을 설명할 수 없다는 상황 앞에서 과학자들이 더 신중한 태도를 보일 것이라 기대했을 법합니다. 하지만 오히려 많은 이들은 LLM을 깊이 과소평가하며, 결국 LLM이란 다소 발전된 마르코프 체인에 불과하고 학습 데이터에서 본 것을 극히 제한적으로 변형해 되뇌는 것이 전부라고 말했습니다. 그러다 증거가 쌓이자 ‘앵무새’라는 비유는 거의 예외 없이 철회되었습니다.

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

어리석지만 박식한 존재

사실 LLM은 아무리 잘해 봐야 기초적인 수준의 추론만 가능하고, 그마저도 부정확한 경우가 많으며 존재하지 않는 사실에 대한 환각으로 점철된 경우가 많습니다. 하지만 LLM은 방대한 지식을 가지고 있습니다. 양질의 데이터를 확보할 수 있는 프로그래밍 분야나 다른 분야에서도 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로 코드를 작성하는 것이었습니다. 그래서 저는 두 가지 문제를 동시에 마주하게 되었습니다. 제가 무의미하다고 생각하는 패턴으로 가득한 Objective-C의 번거로운 BLE API를 익히는 일(저는 미니멀리스트라 그런 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가 테스트 이미지 하나에 대한 원시 출력 값(사실상 로짓)인 일련의 부동소수점 숫자를 관찰한 뒤에야 비로소 네트워크가 어떻게 동작하는지를 ‘이해’했다는 것입니다. 이를 통해 정확한 출력 세부 사항과 정규화 방식, 박스가 중심 기준인지 왼쪽 위 모서리 기준인지 등을 식별할 수 있었습니다.

일회용 프로그램

위에 서술한 것과 같은 사례를 수십 가지 더 문서화할 수도 있을 겁니다. 거의 같은 이야기가 비슷한 방식으로 반복될 뿐이라 의미는 없을 것 같습니다. 문제가 있고, 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%여야 한다는 점을 고려해 주세요. 추가되는 원소는 널 종료 문자열입니다.

출력 결과는 다음과 같습니다.

#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번 효과적으로 해시하면서 해시들이 충분히 서로 비상관되어 있음을 보장하는 방법을 찾는 문제가 있습니다.

하지만 N개의 비상관된 출력을 생성하도록 해시 함수를 수정해 달라고 GPT-4에게 명시적으로 요청하면, 제안하는 해법은 훨씬 더 납득할 만합니다.

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

따라서 시스템 프로그래밍 맥락에서 LLM의 활용처가 전혀 없다고 말하는 것은 분명 부정확합니다.

한 가지 흥미롭고 어느 정도 예상했던 점은, 이런 경우에 작은 모델과 큰 모델의 차이가 실제로 크다는 것입니다. 두 로컬 모델이 어떻게 수행하는지 살펴보겠습니다.

예를 들어 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로 진행한 대화 로그입니다.

같은 데이터에 대해 N개의 서로 다른 해시를 출력해야 하는 해시 함수가 있습니다. 그런데 `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;
}

제가 사용하는 솔팅 전략이 좋지 않은 걸까요? 어떻게 바꾸면 좋을까요?

[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
}

이렇게 하면 단순히 더하는 것보다 `hash_id`가 해시 전반에 더 잘 퍼져 분포가 더 균일해질 수 있습니다. [생략… 실제 답변에는 더 유익한 내용이 포함되어 있었습니다]

MacBook M1 Max에서 동작하는 모델 치고는 나쁘지 않습니다. 합을 XOR로 섞기도 했습니다. 이 경우 모델은 해결해야 할 문제에 대한 힌트를 제가 제공한 덕을 분명히 보았지만, 무엇이 문제의 진짜 원인이었는지를 식별한 것은 모델 자신이었고 제안된 대안도 유효합니다. 위와 같은 결과는 어떤 책이나 문서, 구글 검색으로도 얻을 수 없는 것입니다. 아무리 원시적이고 보간의 결과라거나 어떻게 인식하든, 모델은 어떤 형태로든 추론을 수행했습니다. 적어도 이 특정 사례에서 추론이란 문제의 원인과 잠재적 해결책을 식별하는 것을 의미한다고 한다면 말입니다. LLM을 어떻게 생각하든, LLM이 프로그래머에게 도움이 되지 않는다고 말하는 것은 극히 성급한 판단입니다.

하지만 동시에 지난 몇 달간의 경험에 비추어 보면, 시스템 프로그래밍에서는 이미 숙련된 프로그래머라면 LLM이 거의 수용 가능한 해법을 제공하지 못합니다. 또 다른 실제 사례를 보여드리겠습니다. 현재 진행 중인 프로젝트인 ggufflib에서는 llama.cpp가 양자화된 모델을 로드하는 형식인 GGUF 형식의 파일을 읽고 쓰는 라이브러리를 작성하고 있습니다. 처음에는 양자화 인코딩이 어떻게 동작하는지 이해하기 위해(속도상의 이유로 각 quant의 비트가 교묘한 방식으로 저장되어 있습니다) ChatGPT를 이용해 보려 했지만, 결국 llama.cpp 코드를 역공학하는 편이 훨씬 빨랐습니다. 시스템 프로그래머를 제대로 도울 수 있는 LLM이라면 데이터 인코딩 ‘구조체’ 선언과 디코딩 함수를 보고 데이터 형식 문서를 재구성할 수 있어야 합니다. 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을 프로그래밍에 사용하지 않을 이유가 있을까요? LLM에게 올바른 질문을 던지는 것은 핵심적인 기술입니다. 덜 연습할수록 AI 덕분에 자신의 작업을 개선할 수 있는 능력도 덜 향상될 것입니다. 그리고 문제를 서술하는 능력을 기르는 것은 다른 사람과 대화할 때도 유용합니다. LLM만이 우리가 말하고자 하는 바를 때때로 이해하지 못하는 것이 아닙니다. 제대로 소통하지 못하는 것은 큰 제약이며, 많은 프로그래머가 특정 분야에서는 매우 유능하면서도 소통은 매우 서툽니다. 그리고 이제 구글은 사용할 수 없는 상태가 되었습니다. LLM을 압축된 형태의 문서로 활용하는 것만으로도 좋은 생각입니다. 저로서는 앞으로도 LLM을 폭넓게 계속 사용할 생각입니다. 잘 알지도 못하는 통신 프로토콜의 세부 사항이나, 자신이 얼마나 뛰어난지 보여주고 싶어 하는 누군가가 작성한 복잡하기 짝이 없는 라이브러리의 뒤얽힌 메서드를 익히는 일을 저는 한 번도 좋아한 적이 없습니다. 제게는 ‘쓰레기 지식’처럼 느껴집니다. LLM은 날이 갈수록 그런 모든 것에서 저를 구해 주고 있습니다.

원문은 Salvatore Sanfilippo님이 에 게재했습니다.

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