GHC 깊이 들여다보기: 파이프라인
GHC에 관한 자발적인 글이 더 필요하다는 Simon의 요청을 읽고, 지난 몇 년간 GHC 내부를 다루며 쌓아 온 지식을 공유하기에 좋은 때라고 생각했습니다.
저는 결코 GHC 내부의 전문가는 아니지만, 여러 프로젝트에서 GHC 내부를 꽤 많이 다뤄 보았으며, 깊이 파고드는 형식의 블로그 글은 독자가 스스로 코드를 탐색하기 시작하도록 돕는 데 좋은 형식인 경향이 있습니다. 오픈 소스 프로젝트에 더 깊이 참여하도록 독려하는 데는 대개 고수준의 개요와 실행 가능한 작은 예제 코드만으로도 충분하며, 제가 쓰고자 하는 것이 바로 이것입니다.
이렇게 해서 GHC의 구조에 관한 여러 편의 글을 시작합니다. 이 글들은 컴파일러의 내부 구조 일부를 보여 주는 작은 프로젝트에 GHC API를 사용하는 여러 예제를 중심으로 구성됩니다.
**[Accompaying Source Code](https://github.com/sdiehl/dive-into-ghc/tree/master/01-pipeline)**
공식 해설
GHC Core 개발자들은 실제로 컴파일러 설계에 관한 지식을 공유하는 데 많은 시간을 들여 왔습니다. 시작하기 좋은 자료는 다음과 같습니다.
- GHC 해설
- OPLSS: 타입의 모험
- 2006 GHC 해커톤 영상
- David Terei의 노트
- Takenobu Tani의 GHC Illustrated
- Edward Yang의 블로그
이에 더해 25년 전까지 거슬러 올라가는 문헌의 흐름이 있으며, 이는 오늘날의 GHC에 이르기까지의 역사적 맥락과 연구를 보여 줍니다.
최상위 구조
GHC는 별난 괴물 같은 코드베이스이지만, 컴파일러로서는 어디를 봐야 하는지만 안다면 꽤 잘 설계되고 문서화된 프로젝트입니다. 물론 곳곳에서 다소 독특한 관례를 사용하지만, 결국 20년 된 코드베이스입니다.
컴파일러 소스를 받으려면 공식 저장소를 클론하십시오.
$ git clone --recursive git://git.haskell.org/ghc.git
$ cd ghc/
컴파일러에는 문서와 빌드 시스템을 아우르는 많은 유틸리티가 포함되어 있지만, 컴파일러 자체에 중요한 최상위 디렉터리는 주로 다음과 같습니다.
├── rts # The Haskell runtime systems
├── compiler # The Haskell compiler logic
├── includes # Header files for runtime and code generation
└── libraries # The base libraries and Prelude source
이 글에서는 compiler 폴더를 다루겠습니다.
├── basicTypes # Types used across all modules
├── cbits # Misc C utilities
├── cmm # Cmm langauge definitions
├── codeGen # Cmm Compilers
├── coreSyn # Core language definitions
├── deSugar # Desugarer
├── ghci # Interactive shell
├── hsSyn # Frontend syntax
├── iface # Interface files
├── llvmGen # LLVM Code generator
├── main # Compiler driver logic and options
├── nativeGen # Assemblers for x86 / SPARC / PPC
├── parser # Frontend Parser for HsSyn
├── prelude # Wired-In Types / Primops and Builtins
├── profiling # Runtime profiing tools
├── rename # Frontend renamer
├── simplCore # Core-To-Core simplifier
├── simplStg # Stg-To-Stg simplifier
├── specialise # Specialisation pass ( Eliminates Overloading )
├── stgSyn # Stg Core Language
├── stranal # Strictness Analyzer
├── typecheck # Typechecker
├── types # Type language, data constructors, and type families
├── utils # Misc functions and core data structures
└── vectorise # Vectorisation optimiations
GHC API
GHC 자체가 Haskell로 작성되어 있으므로, GHC는 사실상 GHC API를 아우르는 하나의 거대한 라이브러리입니다. 최상위 모듈의 이름은 그냥 GHC이며, 컴파일 파이프라인을 구동하는 여러 Core 타입을 한 네임스페이스에 모아 둔 모듈입니다.
그 아래에는 일반 Haskell 소스 코드를 컴파일하기 위한 주요 API인 HscMain이 있으며, 여기에는 컴파일 과정의 여러 패스를 위한 다양한 드라이버가 들어 있습니다. 여섯 개의 Core 패스가 컴파일 파이프라인을 이룹니다:
- 파싱
- 이름 변경
- 타입 검사
- 디슈거링
- 단순화
- 코드 생성
이 컴파일의 결과물은 여러 산출물로, 오브젝트 파일(.o), 인터페이스 파일(.hi), 실행 파일입니다.
GHC 모나드
컴파일 과정의 핵심은 GHC 모나드 안에 저장됩니다. GHC 모나드는 컴파일 파이프라인의 내부 세션 상태, 오류 처리, 다중 모듈 컴파일의 순서 지정을 다루는 상태 모나드입니다.
newtype Ghc a = Ghc { unGhc :: Session -> IO a }
추상 클래스 GhcMonad는 GHC 모나드 함수의 리프트된 버전을 제공하여, 컴파일 전반에 걸쳐 사용되는 여러 하위 모나드(renamer, typechecker 등) 안에서 내부 세션 객체에 접근할 수 있게 합니다.
class (Functor m, MonadIO m, ExceptionMonad m, HasDynFlags m) => GhcMonad m where
getSession :: m HscEnv
setSession :: HscEnv -> m ()
평가 함수는 libdir의 경로를 받아 IO 안에 결과를 반환합니다.
runGhc :: Maybe FilePath -> Ghc a -> IO a
이 파일 경로들은 설치 환경마다 다른 경로로, 로컬 설치 위치와 GHC 컴파일러의 경로를 나타냅니다. 이 경로들은 ghc-paths 패키지가 제공합니다.
import GHC.Paths
libdir, docdir, ghc, ghc_pkg :: FilePath
세션 객체의 중심에는 컴파일의 내부 상태를 담고 있는 HscEnv라는 매우 중요한 구조체가 있습니다.
data HscEnv
= HscEnv
{ hsc_dflags :: DynFlags
, hsc_targets :: [Target]
, hsc_mod_graph :: ModuleGraph
, hsc_IC :: InteractiveContext
, hsc_HPT :: HomePackageTable
-- Many more ... (truncated for brevity)
}
hsc_dflags는 설정 객체를 담고 있습니다(이에 대해서는 다음에 자세히 설명합니다). hsc_targets는 모듈 그래프의 루트를 담고 있으며, 이 루트들을 상향식으로 순회하여 현재 패키지를 컴파일하는 데 필요한 모듈 전체 집합을 구성합니다. 여기에 관련된 모듈 전체 집합(루트와 루트가 아닌 것 모두)은 hsc_mod_graph에 저장되며, 이 필드는 ModuleGraph 전체를 담고 있는데 반드시 위상 순서로 정렬되어 있지는 않습니다. hsc_IC 필드는 대화형 컨텍스트를 담고 있으며, 이는 대화형 셸에 사용되고 최종 타깃이 메모리 안에서 링크될 때에도 사용됩니다. 최상위 스코프에 모듈을 추가하는 것과 같은 GHCi의 특정 명령은 이 구조체의 상태를 상태 기반으로 수정합니다.
hsc_HPT는 이미 컴파일된 홈 패키지 모듈을 기술하는 홈 패키지 테이블을 담고 있습니다. 모듈의 컴파일이 끝나고 loadModule로 로드되면 해당 모듈은 내부적으로 이 매핑에 추가됩니다.
DynFlags
DynFlags는 단일 파일 또는 GHC 세션의 컴파일과 관련된 플래그의 모음을 담고 있습니다. 이것은 컴파일이 어떻게 이루어지는지를 결정하는 핵심(Core) 데이터 타입이며, 다양한 패스 함수 대부분에 전달됩니다.
data DynFlags
= DynFlags
{ ghcMode :: GhcMode
, ghcLink :: GhcLink
, hscTarget :: HscTarget
, settings :: Settings
, flags :: [DynFlag]
, extensionFlags :: [ExtensionFlag]
, pkgState :: PackageState
, pkgDatabase :: Maybe [PackageConfig]
, packageEnv :: Maybe FilePath
, packageFlags :: [PackageFlag]
, extraPkgConfs :: [PkgConfRef] -> [PkgConfRef]
-- Many more flags... (truncated for brevity)
}
GhcMode는 다중 모듈 컴파일을 하는지, 아니면 일회성 단일 파일 컴파일을 하는지를 알려 줍니다. 다중 모듈의 경우 ModuleGraph는 홈 패키지에서 의존 모듈을 검색하는 Finder 함수를 통해 구성됩니다.
GhcMode
CompManager --make OneShot ghc -c Foo.hs MkDepend ghc -M
HscTarget 데이터 타입은 컴파일의 대상 코드 유형을 정의합니다. 기본값은 HscAsm입니다.
HscTarget
HscC C 코드를 생성합니다. HscAsm 네이티브 코드 생성기를 사용하여 어셈블리를 생성합니다. HscLlvm llvm 코드 생성기를 사용하여 어셈블리를 생성합니다. HscInterpreted 바이트코드를 생성합니다. HscNothing 어떤 코드도 생성하지 않습니다. 위의 참고 사항을 보십시오.
(다중 모듈의 경우) 컴파일이 끝나면 GHC는 링커 단계를 시작하며, GhcLink 설정이 생성된 오브젝트 파일을 어떻게 처리할지 결정합니다.
GhcLink
NoLink 링크를 전혀 하지 않습니다. LinkBinary 오브젝트 코드를 바이너리로 링크합니다. LinkInMemory 인메모리 동적 링커를 사용합니다(바이트코드와 오브젝트 코드 모두에서 동작합니다). LinkDynLib 오브젝트를 동적 라이브러리로 링크합니다(Windows에서는 DLL, ELF 플랫폼에서는 DSO). LinkStaticLib 오브젝트를 정적 라이브러리로 링크합니다.
GHC 세션의 가장 단순한 초기화 방식은 기본값을 그대로 사용하여, 주어진 모든 모듈을 메모리에서 링크하는 인터프리터 세션을 설정하는 것입니다.
example :: IO ()
example = runGhc (Just libdir) $ do
dflags <- getSessionDynFlags
setSessionDynFlags $ dflags { hscTarget = HscInterpreted
, ghcLink = LinkInMemory
}
GHC는 명령줄에서 많은 컴파일러 플래그를 제공하며, 이 플래그들은 DynFlags 구조체의 여러 하위 필드에 반영됩니다. 플래그의 세 가지 주요 부류는 DumpFlag(예: -ddump-simpl), GeneralFlag(예: -fspec-constr), ExtensionFlag(예: -XTypeInType)입니다. DynFlags를 수정하여 이러한 플래그를 켜거나 끄는 다양한 헬퍼 함수가 있습니다.
dopt_set :: DynFlags -> DumpFlag -> DynFlags
gopt_set :: DynFlags -> GeneralFlag -> DynFlags
xopt_set :: DynFlags -> ExtensionFlag -> DynFlags
컴파일 과정 전반에서 GHC는 이러한 플래그의 상태를 조회하여, 언어 확장이 설정되어 있는지 또는 기타 플래그 동작에 따라 서로 다른 코드 경로로 분기합니다. 이는 GhcMonad 인스턴스를 조회하여 dynflags를 얻은 뒤, 플래그별 여러 함수 중 하나를 사용하는 방식으로 이루어집니다.
xopt :: ExtensionFlag -> DynFlags -> Bool
gopt :: GeneralFlag -> DynFlags -> Bool
dopt :: DumpFlag -> DynFlags -> Bool
여러 플래그를 활성화하려면 플래그 설정 함수를 사용하여 현재 dflags 객체를 수정합니다.
example :: IO ()
example = runGhc (Just libdir) $ do
dflags <- getSessionDynFlags
let dflags' = dflags { hscTarget = HscInterpreted , ghcLink = LinkInMemory }
`dopt_set` Opt_D_dump_BCOs -- Set Dump Flag
`xopt_set` Opt_OverloadedStrings -- Set Language Extension Flag
컴파일
컴파일을 시작하려면 먼저 상태에 타깃을 추가합니다. 이는 환경의 hsc_targets 필드를 수정합니다. 타깃은 모듈 이름 또는 파일 이름의 두 가지 유형 중 하나입니다. guessTarget은 주어진 문자열의 확장자를 보고 어떤 타깃 객체를 생성할지 판별합니다.
addTarget :: GhcMonad m => Target -> m ()
guessTarget :: GhcMonad m => String -> Maybe Phase -> m Target
타깃은 의존성 트리의 최상위에 있는 소스 파일이나 모듈을 지정합니다. 실행 프로그램의 경우 타깃은 Main.hs 하나뿐이며, 라이브러리의 경우 타깃은 라이브러리에서 공개된 모듈들입니다.
Target
TargetModule 모듈 이름: 해당 파일을 검색합니다. TargetFile FilePath 파일 이름: 전처리하고 파싱하여 모듈 이름을 찾습니다.
상태에 모듈을 추가하고 나면, 의존성 분석을 수행하여 모듈 그래프를 결정하고 다중 모듈 컴파일을 진행할 수 있습니다. 의존성 분석은 모듈의 import 지시문을 파싱하고 ModuleGraph를 해석하는 작업으로, ModuleGraph는 타깃들을 담고 있는 ModuleSummary 리스트의 타입 별칭입니다. 이 작업은 depanal 함수가 수행합니다.
depanal :: GhcMonad m => [ModuleName] -> Bool -> m ModuleGraph
타깃이 생성되면 컴파일러가 모듈에 대해 실행되어 결과 산출물을 생성하고, 이는 홈 패키지 테이블에 로드됩니다. 이는 load 명령을 통해 이루어집니다.
load :: GhcMonad m => LoadHowMuch -> m SuccessFlag
LoadHowMuch
LoadAllTargets 모든 타깃과 그 의존성을 로드합니다. LoadUpTo 주어진 모듈과 그 의존성만 로드합니다. LoadDependenciesOf 주어진 모듈 자체는 제외하고 그 모듈의 의존성만 로드합니다.
이에 대한 전체 예제는 현재 작업 디렉터리에 있는 모듈 Example.hs를 인터프리트하여 메모리에서 링크하는 방식으로 컴파일하는 것입니다.
example :: IO ()
example = runGhc (Just libdir) $ do
dflags <- getSessionDynFlags
setSessionDynFlags $ dflags { hscTarget = HscInterpreted
, ghcLink = LinkInMemory
}
target <- guessTarget "Example.hs" Nothing
addTarget target
load LoadAllTargets
인터랙티브 컨텍스트
단순히 컴파일러 산출물을 생성하는 것 외에도 할 수 있는 일이 있습니다. GHC는 코드를 메모리 안에서 컴파일하고 링크하여 대화식으로 평가할 수 있습니다. 이를 뒷받침하는 인터프리터의 상태는 InteractiveContext에 보관됩니다.
대화식 스코프에 있는 모듈 집합은 setContext 함수로 수정할 수 있습니다.
getContext :: GhcMonad m => m [InteractiveImport]
setContext :: GhcMonad m => [InteractiveImport] -> m ()
모듈이 인터프리트되어 대화식 임포트로 로드되면 해당 모듈의 최상위 스코프 전체를 사용할 수 있습니다. 여러 함수를 사용하여 이 스코프를 조작하고, 조회하고, 확장할 수 있습니다.
parseName을 사용하면 주어진 문자열에서 이름(하나 또는 여러 개)을 대화식 컨텍스트의 심볼 집합으로 해석할 수 있습니다. 이 함수는 Name 객체(자세한 내용은 뒤에서 다룹니다)를 반환하는데, 이는 위치와 고유 식별자를 담고 있는 GHC 내부의 이름 타입입니다.
parseName :: GhcMonad m => String -> m [Name]
주어진 표현식의 타입을 알아내려면 exprType을 사용하여 현재 컨텍스트 안에서 타입 정보를 추출할 수 있습니다.
exprType :: GhcMonad m => String -> m Type
또한 대화식 컨텍스트 전체에서 임포트로 스코프에 들어온 모든 이름의 집합을 조회할 수 있습니다. 이는 대화식 :browse 명령에 사용됩니다.
getNamesInScope :: GhcMonad m => m [Name]
그리고 가장 중요한 기능은 대화식 컨텍스트 안에서 임의의 표현식을 평가하는 것입니다. 이는 dynCompileExpr을 통해 이루어집니다. 이 함수는 Dynamic을 반환하며, 이 값은 Typeable의 어떤 인스턴스에 대해서든 fromDynamic을 사용하여 안전하게 캐스트할 수 있습니다. 이는 대화식 컨텍스트 안에서 문자열 표현식을 동적으로 평가하는 데 사용됩니다.
dynCompileExpr :: GhcMonad m => String -> m Dynamic
fromDynamic :: Typeable a => Dynamic -> a -> Maybe a
패키지 데이터베이스
기본 상태에서 GHC는 두 개의 패키지 데이터베이스를 알고 있습니다. 하나는 /usr/lib/ghc-x.x.x/에 있는 전역 패키지 데이터베이스이고, 다른 하나는 ~/.ghc/lib에 있는 사용자 데이터베이스입니다.
그러나 이는 “GHC_PACKAGE_PATH” 환경 변수를 통해 확장할 수 있습니다. 이 변수에 지정된 경로를 읽은 뒤 extraPkgConfs 함수를 적용하여 패키지 데이터베이스에 추가합니다. 이 방식은 cabal과 stack 같은 도구에서 쓰이는 여러 현대적인 샌드박싱 기법에 사용됩니다.
extraPkgConfs :: [PkgConfRef] -> [PkgConfRef]
주어진 dynflags를 파일 경로로 수정하려면 다음 함수를 사용하여 상태를 확장할 수 있습니다.
addPkgDbs :: GhcMonad m => [FilePath] -> m ()
addPkgDbs fps = do
dfs <- getSessionDynFlags
let pkgs = map PkgConfFile fps
let dfs' = dfs { extraPkgConfs = (pkgs ++) . extraPkgConfs dfs }
setSessionDynFlags dfs'
_ <- initPackages dfs'
return ()
Stack은 stack repl로 셸을 실행할 때 이 값을 설정합니다. 이 값을 수정하는 방법은 뒤에서 더 자세히 다룹니다.
미니 GHCi
자, 그러면 GHC용 아주 작은 대화식 셸을 만들어 봅시다. Haskeline(플랫폼에 독립적인 readline 추상화)에 익숙하지 않다면 먼저 그것부터 읽어 보십시오.
Haskeline 인터페이스는 모나드 트랜스포머 InputT로 제공되며, 이를 IO 위에 얹으면 대화식 repl 모나드를 얻을 수 있습니다.
type Repl a = InputT IO a
초기 세션을 설정하려면 기본 dynflags를 가져오고, 대상이 인터프리트되고 메모리에 링크되도록 설정한 뒤, -XExtendedDefaultRules 플래그를 조정합니다. 대화식 셸이 Prelude를 임포트하도록 설정한 다음, 만들어진 세션을 모나드 방식으로 반환하여 셸 명령마다 그 세션에 점진적으로 내용을 추가할 수 있게 합니다.
initSession :: IO HscEnv
initSession = runGhc (Just libdir) $ do
liftIO $ putStrLn "Setting up HscEnv"
dflags <- getSessionDynFlags
let dflags' = dflags { hscTarget = HscInterpreted , ghcLink = LinkInMemory }
`xopt_set` Opt_ExtendedDefaultRules
setSessionDynFlags dflags'
setContext [ IIDecl $ simpleImportDecl (mkModuleName "Prelude") ]
env <- getSession
return env
그다음 각 대화식 셸 명령은 헬퍼 함수 session으로 감싸집니다. 이 함수는 새로운 Ghc 모나드를 띄우되 직전 컴파일의 세션을 복원합니다. 그런 다음 모나드 동작을 평가하고, 그 후에 만들어진 세션을 재사용할 수 있도록 값으로 반환합니다.
session :: HscEnv -> Ghc a -> IO HscEnv
session env m = runGhc (Just libdir) $ do
setSession env
m
env <- getSession
return env
평가자 함수는 서로 다른 두 가지 컴파일 단계를 시도합니다. 먼저 표현식을 있는 그대로 컴파일하여 IO a 액션으로 평가되는지 확인합니다. 그렇다면 해당 표현식은 모나드 안에서 직접 평가됩니다. 그렇지 않다면 fromDynamic 캐스트는 단순히 Nothing을 내놓고, 우리는 표현식을 print 문으로 감싸 보려고 시도합니다. 이렇게 컴파일된 표현식은 IO a임이 보장되므로, GHC가 넘겨주는 컴파일된 코드 포인터를 IO로 안전하지 않게 강제 변환(coerce)하여 실행합니다.
eval :: String -> Ghc ()
eval inp = do
dyn <- fromDynamic <$> dynCompileExpr inp
case dyn of
Nothing -> do
act <- compileExpr ("Prelude.print (" <> inp <> ")")
-- 'print' is constrained to 'IO ()' so unsafeCoerce is "safe"
liftIO (unsafeCoerce act)
Just act -> liftIO $ act
import를 추가하려면 해당 import를 모듈 이름으로 컨텍스트에 cons한 다음 새 상태를 내놓기만 하면 됩니다.
addImport :: String -> Ghc ()
addImport mod = do
ctx <- getContext
setContext ( (IIDecl $ simpleImportDecl (mkModuleName mod)) : ctx )
그다음에는 던져지는 모든 예외를 잡아서 그냥 출력해 버리는 좋지 않은 방식을 사용합니다. 표현식 컴파일이 실패하면 내장 인터프리터 로직 안에서 그 실패를 가로채 보고하기만 하면 되는 상황이므로, 이는 꽤 정당화될 수 있습니다.
ghcCatch :: MonadIO m => IO a -> m (Maybe a)
ghcCatch m = liftIO $ do
mres <- try m
case mres of
Left (err :: SomeException) -> do
liftIO $ print err
return Nothing
Right res -> return (Just res)
그러면 REPL은 사용자의 입력을 읽고, 그 줄이 키워드 import로 시작하는지에 따라 분기하기만 합니다. 그런 다음 표현식 줄에 따라 직전 줄에서 유지하고 있던 현재 HscEnv로 GHC 세션을 띄우고 컴파일을 시도합니다. 성공하면 새 env 상태로 repl을 호출하며, 이를 끝없이 반복합니다. 중단을 위한 Unix 시그널은 Haskeline 모나드가 처리합니다.
repl :: HscEnv -> Repl ()
repl env = do
minput <- getInputLine ">>> "
case minput of
Nothing -> outputStrLn "Goodbye."
Just input | "import" `isPrefixOf` input -> do
let mod = concat $ tail $ words input
env' <- ghcCatch (session env (addImport mod))
maybe (repl env) repl env'
Just input -> do
env' <- ghcCatch (session env (eval input))
maybe (repl env) repl env'
이제 이 모두를 하나로 합칩니다.
main :: IO ()
main = do
env <- initSession
runInputT defaultSettings (repl env)
그러면 우리의 작은 셸을 실행할 수 있습니다.
$ stack build dive
$ stack exec dive
Setting up HscEnv
>>> fmap (+1) [1..10]
[2,3,4,5,6,7,8,9,10,11]
>>> import Data.Text
>>>
Goodbye.
이것이 우리가 직접 만든 Mini GHCi입니다. 실제로 진짜 GHCi는 조금 다르게 동작하지만, 밑바탕의 일부 메커니즘은 동일합니다. 이름 조회나 내부 검사(introspection) 같은 다른 기능은 독자의 연습 문제로 남겨 둡니다. 다음으로 해 볼 만한 재미있는 프로젝트는 스코프 안에 있는 임의의 정의의 원본 소스 코드를 조회하는 내부 검사 도구를 갖춘 작은 셸을 만드는 것입니다.
요약 및 다음 단계
이것이 GHC와 상호작용하는 데 사용할 수 있는 “매우 고수준(Very High Level)” API입니다. 다음으로는 내부에서 사용되는 산출물의 속 구조와, 이를 프로그래밍 방식으로 검사하고 구축하는 방법을 다루겠습니다.