Extracting Emacs LIMPLE (Native Compilation)
So, instead of re-building a bytecode analyzer (see Bytecode.html), it is
best we utilize what Emacs' native-comp provides: it converts Emacs bytecode
into LIMPLE, an intermediate representation (IR) suitable for libgccjit. If we
are to use Truffle bytecode DSL, LIMPLE is definitely more convenient than
bytecode. And this document aims to extract specification for LIMPLE similar to
what Bytecode.html does.
1. LIMPLE Operators
One may think of LIMPLE as structured bytecode, and it also consists of various "instructions". Each instruction is a list, led by a symbol - an operator.
These operators are defined in comp-common.el, with comp-limple-ops holding
all of them:
(require 'comp-common) (require 'cl-macs) (defvar op-return '(return)) (let* ((op-table '((op-return comp-limple-calls comp-limple-assignments comp-limple-branches))) (op-lists (mapcar #'symbol-value (car op-table))) (table-height (apply #'max (mapcar #'length op-lists)))) (let ((all-ops (apply #'append op-lists))) (cl-assert (equal (sort comp-limple-ops) (sort all-ops)))) (dotimes (i table-height (nreverse op-table)) (push (mapcar (lambda (list) (let ((op (nth i list))) (if op (format "=%S=" op) ""))) op-lists) op-table)))
| op-return | comp-limple-calls | comp-limple-assignments | comp-limple-branches |
return |
call |
assume |
jump |
callref |
fetch-handler |
cond-jump |
|
direct-call |
set |
||
direct-callref |
setimm |
||
set-par-to-local |
|||
set-args-to-local |
|||
set-rest-args-to-local |
2. Data Structures
Emacs native-comp organizes LIMPLE instructions and various metadata into a
dozen data structures:
- Context:
(cl-defstruct (comp-ctxt (:include comp-cstr-ctxt)) ...)- Context with constraints:
(cl-defstruct comp-cstr-ctxt)
- Context with constraints:
- Function:
(cl-defstruct (comp-func (:copier nil)) ...)- Lexical-scoped function:
(cl-defstruct (comp-func-l (:include comp-func)) ...) - Dynamic-scoped function:
(cl-defstruct (comp-func-d (:include comp-func)) ...)
- Lexical-scoped function:
- LIMPLE blocks:
(cl-defstruct (comp-block ...) ...)- …
- References:
(cl-defstruct (comp-mvar (:constructor make--comp-mvar0)) ...) - …
There are a lot of them and comp.c actually accesses these structure data,
emitting GCC IR. It certainly is much more complex than bytecode… Let's see if
we can break it down.
2.1. LIMPLE Experiments
Let's first try to native-compile a function to see what happens. Upon async native compilation, Emacs launches another Emacs instance and sends LIMPLE data over, which we may intercept to take a peek at the intermediate products:
(require 'cl-macs) (defvar intercept-limple nil) (defvar intercept-limple-products nil) (define-advice delete-file (:before (filename &optional trash)) (when (and intercept-limple (string-suffix-p ".el" filename)) (cl-assert (string-match-p "emacs-int-comp-" filename)) (unless intercept-limple-products (with-temp-buffer (insert-file-contents filename) (setq intercept-limple-products (buffer-string)))))) (defun intercept-native-compile (symbol) "Returns the contents of the file Emacs sends for native-compilation." (cl-assert (and (symbolp symbol) (symbol-function symbol))) (let ((native-comp-jit-compilation nil) (intercept-limple t) intercept-limple-products) (native-compile symbol) intercept-limple-products))
And this is our first function and what it compiles (half-way) to:
(defun test-func () 1234567890) (intercept-native-compile 'test-func)
;; -*-coding: utf-8-emacs-unix; -*- (require 'comp) (setf native-comp-verbose 0 comp-libgccjit-reproducer nil comp-ctxt #s(comp-ctxt (...) ; typeof-types (from `comp-cstr-ctxt') ... ; other fields from `comp-cstr-ctxt' "....eln" ; output 2 ; speed 1 ; safety 0 ; debug nil ; compiler-options nil ; driver-options ;; `top-level-forms': list of `byte-to-native-func-def' and more (...) ;; `funcs-h': Map<string, comp-func> #s(hash-table ...) ;; `sym-to-c-name-h': Map<symbol, string> #s(hash-table ...) ;; various other fields #s(hash-table test equal) ; `byte-func-to-func-h' #s(hash-table test equal) ; `lambda-fixups-h' #s(hash-table data (0 nil)) ; `function-docs' ... ; various `comp-data-container' for relocation nil ; `with-late-load' ) native-comp-eln-load-path '(...) native-comp-compiler-options 'nil native-comp-driver-options 'nil byte-compile-warnings 't load-path '(...) ) nil (message "Compiling %s..." '"....eln") (comp--final1)
Well, it's certainly not pretty and it did take me a while to manually
pretty-format this and the following block. But anyways, since we are compiling
a function, we are most interested in the funcs-h field of the comp-ctxt
data Emacs passed over: 1
;;; `funcs-h': #s(hash-table-start ;; key1: "F746573742d66756e63_test_func_0" ;; value1: #s(comp-func-d test-func ; `name': symbol "F746573742d66756e63_test_func_0" ; `c-name': string #[nil "\300\207" [1234567890] 1] ; `byte-func': bytecode-closure nil ; `doc': string nil ; `int-spec': form nil ; `command-modes': what? ;; `lap': LAP (bytecode) ((byte-constant 1234567890 . 0) (byte-return . 0)) t ; `ssa-status' 1 ; `frame-size' 0 ; `vframe-size' ;; `blocks': Map<symbol, comp-block> #s(hash-table test eq data (entry #3=#s(comp-block-lap entry ; `name' ;; `insns': the instructions (LIMPLE!) ((comment "Lisp function: test-func") (jump bb_0)) nil ; `closed' ;; `in-edges' & `out-edges': list of `comp-edge' nil (#s(comp-edge #3# #6# 0)) #3# ; `idom': "immediate dominator"? #s(hash-table) ; `df': "dominator frontier set"? 1 ; `post-num': post order number #s(comp-vec ...) ; `final-frame' -1 nil ; `sp' & `addr': used during limpification nil ; `non-ret-insn' nil ; `no-ret' ) bb_0 #6=#s(comp-block-lap bb_0 ; `name' ;; `insns' ((setimm #4=#s(comp-mvar nil nil ((1234567890 . 1234567890)) nil 21261352245837 0) 1234567890) (return #4#)) nil (#s(comp-edge #3# #6# 0)) nil ; `closed', `in-edges', `out-edges' #3# #s(hash-table) ; `idom', `df' 0 ; `post-num' #s(comp-vec ...) ; `final-frame' -1 0 nil nil))) ;; `lap-block': label -> block name, `edges-h': number -> edge #s(hash-table ...) #s(hash-table ...) ;; `block-cnt-gen', `edge-cnt-gen': two closures ... nil ; `has-non-local' 2 ; `speed' 1 ; `safety' nil ; `pure' nil ; `declared-type' #s(comp-mvar nil (nil) nil nil nil nil) ; `return-type' nil ; `args' ) ;; key2: #8="top_level_run" ;; value2: #s(comp-func-l top-level-run "top_level_run" nil nil nil nil nil t 2 0 ;; `blocks': Map<symbol, comp-block> #s(hash-table test eq data (entry #s(comp-block-lap entry ((comment "Top level") (set-par-to-local #11=#s(comp-mvar #9# nil nil nil 21261321342068 0) 0) (set #12=#s(comp-mvar #9# nil nil nil 21261321342126 1) (call comp--register-subr #s(comp-mvar nil (test-func) nil nil nil nil) #s(comp-mvar nil (#1#) nil nil nil nil) #s(comp-mvar nil (#13=(0 . 0)) nil nil nil nil) #s(comp-mvar nil (nil) nil nil nil nil) #s(comp-mvar nil (nil) nil nil nil nil) #s(comp-mvar nil (#14=(0 nil nil)) nil nil nil nil) #11#)) (return #12#)) nil nil nil nil #s(hash-table) nil #s(comp-vec #s(hash-table data (0 #11# 1 #12#)) 0 2) -1 nil nil nil))) #s(hash-table test equal) #s(hash-table) #[0 #7# [(-1)] 2] #[0 #7# [(-1)] 2] nil 2 1 nil nil nil #s(comp-args 1 1)) ;;; hashtable-end)
A mess as it seems, we can still extract some information from funcs-h:
- When compiling one function (
test-func),native-compemits twocomp-func, with one (top_level_run) dedicated to registering the function usingcomp--register-subr. - The
#s(comp-func-d test-func ...)function contains two blocks. One is calledentryand contains only one instruction(jump bb_0)that jumps into the function body. #s(comp-mvar ...)serves two purposes:- Immediate value:
#s(comp-mvar ... (nil) ... nil)is just anilimmediate value. - Left value container (or stack slot):
#(comp-mvar ... 0)is stack slot #0.
- Immediate value:
2.2. Prett Printing
For obvious reasons, I don't want to read through prin1 clutter like this ever
again, so let's pretty-print it with comp--log-func:
(defun extract-setf-form (results) (with-temp-buffer (insert results) (goto-char (point-min)) (let (setf-form) (while (not (eq (car-safe setf-form) 'setf)) (setq setf-form (read (current-buffer)))) setf-form))) (defun prune-mvar-hash (func) (cl-loop for block being the hash-values of (comp-func-blocks func) do (cl-loop for insn in (comp-block-insns block) do (cl-loop for operand in insn do (if (and (comp-mvar-p operand) (numberp (comp-mvar-id operand))) (setf (comp-mvar-id operand) (or (comp-mvar-slot operand) -1))))))) (define-advice comp--prettyformat-mvar (:override (mvar)) (let ((slot (comp-mvar-slot mvar)) (id (comp-mvar-id mvar)) (spec (comp-cstr-to-type-spec mvar))) (cond ((numberp slot) (format "#(mvar@%s)" slot)) ((and (null slot) (null id)) (format "#(mvar=%S)" spec)) (t (format "#(mvar %s %S)" slot spec))))) (defun pp-intercept-native-compile (symbol) (with-temp-buffer (let* ((results (intercept-native-compile 'test-func)) (setf-form (extract-setf-form results)) (comp-ctxt (plist-get (cdr setf-form) 'comp-ctxt)) (funcs-h (comp-ctxt-funcs-h comp-ctxt)) (comp-log-buffer-name (current-buffer))) (maphash (lambda (_ func) (prune-mvar-hash func) (comp--log-func func -1)) funcs-h) (buffer-string))))
(defun test-func () 1234567890) (pp-intercept-native-compile 'test-func)
Function: test-func <entry> (comment "Lisp function: test-func") (jump bb_0) <bb_0> (setimm #(mvar@0) 1234567890) (return #(mvar@0)) Function: top-level-run <entry> (comment "Top level") (set-par-to-local #(mvar@0) 0) (set #(mvar@1) (call comp--register-subr #(mvar=(member test-func)) #(mvar=(member "F746573742d66756e63_test_func_0")) #(mvar=(member (0 . 0))) #(mvar=null) #(mvar=null) #(mvar=(member (0 nil nil))) #(mvar@0))) (return #(mvar@1))
So much better. Let's play with it a bit more:
Dynamic function arguments are turned into
symbol-valueinvocation:(defun test-func (arg) arg) (pp-intercept-native-compile 'test-func)
... <entry> (comment "Lisp function: test-func") (jump bb_0) <bb_0> (set #(mvar@0) (call symbol-value #(mvar=(member arg)))) (return #(mvar@0)) ...Lexical function arguments are injected by the prologue (
entryblock) withset-par-to-local:(defun test-func (arg) arg) (pp-intercept-native-compile 'test-func)
... <entry> (comment "Lisp function: test-func") (set-par-to-local #(mvar@0) 0) (jump bb_0) <bb_0> (return #(mvar@0)) ...Conditional statements are transformed into blocks (with
cond-jumpusages):(defun test-func (arg) (if arg 1 0)) (pp-intercept-native-compile 'test-func)
... <entry> (comment "Lisp function: test-func") (set-par-to-local #(mvar@0) 0) (jump bb_0) <bb_0> (set #(mvar@1) #(mvar@0)) (cond-jump #(mvar@1) #(mvar=null) bb_2 bb_1) <bb_2> (setimm #(mvar@1) 0) (return #(mvar@1)) <bb_1> (setimm #(mvar@1) 1) (return #(mvar@1)) ...Loops are also broken into blocks:
(defun test-func () (while (external-func) (step))) (pp-intercept-native-compile 'test-func)
... <entry> (jump bb_0) <bb_0> (setimm #(mvar@0) external-func) (set #(mvar@0) (callref funcall #(mvar@0))) (cond-jump #(mvar@0) #(mvar=null) bb_2 bb_1) <bb_2> (return #(mvar@0)) <bb_1> (setimm #(mvar@0) step) (set #(mvar@0) (callref funcall #(mvar@0))) (jump bb_3_latch) <bb_3_latch> (call comp-maybe-gc-or-quit) (jump bb_0) ...
Note that
external-funcandstephere are also found in thecomp-data-containerfields incomp-ctxt, possibly marked for relocation:;;; ... #s(comp-data-container nil #s(hash-table test comp-imm-equal-test data (external-func t nil t step t #32# t)))
Signal handling is hard:
(defun test-func () (condition-case err (external-func) (error t))) (pp-intercept-native-compile 'test-func)
... <bb_0> (setimm #(mvar@0) (error)) (push-handler condition-case #(mvar@0) bb_3 bb_1) <bb_3> (fetch-handler #(mvar@0)) (jump bb_2) <bb_2> (setimm #(mvar@1) t) (return #(mvar@1)) <bb_1> (setimm #(mvar@0) external-func) (set #(mvar@0) (callref funcall #(mvar@0))) (pop-handler) (return #(mvar@0)) ...
Now, we can also notice that many fields in the structs are only used during
LIMPLE optimization. So, next we are going to look into comp.c to see what
actually get used so as to not miss anything.
2.3. TODO Look into what LIMPLE other special forms produce
So push-handler, pop-handler are not in comp-limple-ops. Emacs bytecode has
a bunch of op codes for representing special forms like condition-case and
catch. We will see how LIMPLE handles them.
3. comp.c
The real compilation from LIMPLE to binary starts at comp--final1, which
contains no more than five lines of code:
(defun comp--final1 () (comp--init-ctxt) (unwind-protect (comp--compile-ctxt-to-file (comp-ctxt-output comp-ctxt)) (comp--release-ctxt)))
comp--init-ctxt: It tries to acquire agcc_jit_context, registers a bunch of emitters for inlinable/non-local-exit constructs, and adds Emacs built-in data types to the context.comp--release-ctxt: Releases the context.comp--compile-ctxt-to-file: Does final relocation, checks output file and finally callscomp--compile-ctxt-to-file0, jumping into the C side.
3.1. From Lisp Through C Back To Lisp
Because most of the structs (like comp-ctxt) are defined with cl-defstruct,
comp.c makes heavy use of macros like CALL1I, CALL2I, CALL3I, ... so as to
access fields in these structs. Let's try to extract what Lisp function it
calls:
(defvar CALLNI-functions nil) (with-temp-buffer (insert-file-contents "../elisp/emacs/src/comp.c") (goto-char (point-min)) (let (functions) (while (search-forward-regexp "CALL\\([0-9N]\\)I[[:space:]]*(\\([^,)]+\\)\\(:?,\\|)\\)" nil t) (let ((function (string-trim (match-string 2))) (n (read (match-string 1)))) (when (not (string= function "fun")) (push (list n function) functions)))) (setq CALLNI-functions (seq-uniq (sort functions :key #'cadr))) (cons (list "CALL[N]I" "Function Name") CALLNI-functions)))
| CALL[N]I | Function Name |
| 0 | backtrace |
| 2 | cl-typep |
| 1 | comp-args-max |
| 1 | comp-block-insns |
| 1 | comp-clean-up-stale-eln |
| 1 | comp-cstr-imm |
| 1 | comp-cstr-imm-vld-p |
| 1 | comp-ctxt-byte-func-to-func-h |
| 1 | comp-ctxt-compiler-options |
| 1 | comp-ctxt-d-default |
| 1 | comp-ctxt-d-ephemeral |
| 1 | comp-ctxt-d-impure |
| 1 | comp-ctxt-debug |
| 1 | comp-ctxt-driver-options |
| 1 | comp-ctxt-funcs-h |
| 1 | comp-ctxt-function-docs |
| 1 | comp-ctxt-speed |
| 1 | comp-data-container-idx |
| 1 | comp-data-container-l |
| 2 | comp-delete-or-replace-file |
| 1 | comp-func-blocks |
| 1 | comp-func-c-name |
| 1 | comp-func-frame-size |
| 1 | comp-func-has-non-local |
| 1 | comp-func-l-args |
| 1 | comp-func-l-p |
| 1 | comp-func-name |
| 1 | comp-func-safety |
| 1 | comp-func-speed |
| 1 | comp-mvar-slot |
| 2 | comp-mvar-type-hint-match-p |
| 1 | comp-nargs-p |
| 1 | file-name-base |
| 1 | file-name-sans-extension |
| 1 | hash-table-count |
| 2 | make-directory |
| 4 | make-temp-file |
| 1 | split-string |
We want to know what fields in these cl-struct objects the compiler needs
access to (so as to write our compiler), so let's try to extract them out of
these function names:
(require 'pcase) (with-temp-buffer (pcase-dolist (`(,n ,name) CALLNI-functions) (when (string-prefix-p "comp-" name) (let* ((symbol (intern-soft name)) (function (symbol-function symbol)) (doc (function-documentation function)) (doc-parts (if doc (string-split doc "\n\n") '("nil" "(fn ...)"))) (doc (string-replace "\n" "\n "(car doc-parts))) (sig (string-replace "fn" name (cadr doc-parts)))) (cl-assert (and symbol function)) (cl-assert (length= doc-parts 2)) (insert (format "- =%s= ::\n %s\n" sig doc))))) (pcase-dolist (`(,regexp . ,replace) '(("`\\([A-Za-z-]+\\)'" . "=\\1=") ("\n[[:space:]]+" . "\n "))) (goto-char (point-min)) (while (re-search-forward regexp nil t) (replace-match replace nil nil))) (buffer-string))
(comp-args-max CL-X)- Access slot "max" of
comp-argsstruct CL-X. Maximum number of arguments allowed. (comp-block-insns CL-X)- Access slot "insns" of
comp-blockstruct CL-X. List of instructions. (comp-clean-up-stale-eln FILE)- Remove all FILE*.eln* files found in
native-comp-eln-load-path. The files to be removed are those produced from the original source filename (including FILE). (comp-cstr-imm CSTR)- Return the immediate value of CSTR.
comp-cstr-imm-vld-pmust be satisfied before callingcomp-cstr-imm. (comp-cstr-imm-vld-p CSTR)- Return t if one and only one immediate value can be extracted from CSTR.
(comp-ctxt-byte-func-to-func-h CL-X)- Access slot "byte-func-to-func-h" of
comp-ctxtstruct CL-X. byte-function -> comp-func. Needed to replace immediate byte-compiled lambdas with the compiled reference. (comp-ctxt-compiler-options CL-X)- Access slot "compiler-options" of
comp-ctxtstruct CL-X. Options for the GCC compiler. (comp-ctxt-d-default CL-X)- Access slot "d-default" of
comp-ctxtstruct CL-X. Standard data relocated in use by functions. (comp-ctxt-d-ephemeral CL-X)- Access slot "d-ephemeral" of
comp-ctxtstruct CL-X. Relocated data not necessary after load. (comp-ctxt-d-impure CL-X)- Access slot "d-impure" of
comp-ctxtstruct CL-X. Relocated data that cannot be moved into pure space. This is typically for top-level forms other than defun. (comp-ctxt-debug CL-X)- Access slot "debug" of
comp-ctxtstruct CL-X. Default debug level for this compilation unit. (comp-ctxt-driver-options CL-X)- Access slot "driver-options" of
comp-ctxtstruct CL-X. Options for the GCC driver. (comp-ctxt-funcs-h CL-X)- Access slot "funcs-h" of
comp-ctxtstruct CL-X. c-name -> comp-func. (comp-ctxt-function-docs CL-X)- Access slot "function-docs" of
comp-ctxtstruct CL-X. Documentation index -> documentation (comp-ctxt-speed CL-X)- Access slot "speed" of
comp-ctxtstruct CL-X. Default speed for this compilation unit. (comp-data-container-idx CL-X)- Access slot "idx" of
comp-data-containerstruct CL-X. Obj -> position into the previous field. (comp-data-container-l CL-X)- Access slot "l" of
comp-data-containerstruct CL-X. Constant objects used by functions. (comp-delete-or-replace-file OLDFILE &optional NEWFILE)- Replace OLDFILE with NEWFILE. When NEWFILE is nil just delete OLDFILE. Takes the necessary steps when dealing with OLDFILE being a shared library that might be currently loaded into a running Emacs session.
(comp-func-blocks CL-X)- Access slot "blocks" of
comp-funcstruct CL-X. Basic block symbol -> basic block. (comp-func-c-name CL-X)- Access slot "c-name" of
comp-funcstruct CL-X. The function name in the native world. (comp-func-frame-size CL-X)- Access slot "frame-size" of
comp-funcstruct CL-X. (comp-func-has-non-local CL-X)- Access slot "has-non-local" of
comp-funcstruct CL-X. t if non local jumps are present. (comp-func-l-args CL-X)- Access slot "args" of
comp-func-lstruct CL-X. Argument specification of the function (comp-func-l-p ...)- nil
(comp-func-name CL-X)- Access slot "name" of
comp-funcstruct CL-X. Function symbol name. Nil indicates anonymous. (comp-func-safety CL-X)- Access slot "safety" of
comp-funcstruct CL-X. Safety level (seesafety). (comp-func-speed CL-X)- Access slot "speed" of
comp-funcstruct CL-X. Optimization level (seenative-comp-speed). (comp-mvar-slot CL-X)- Access slot "slot" of
comp-mvarstruct CL-X. Slot number in the array if a number orscratchfor scratch slot. (comp-mvar-type-hint-match-p MVAR TYPE-HINT)- Match MVAR against TYPE-HINT. In use by the back-end.
(comp-nargs-p ...)- nil
These functions can be roughly categorized:
- The actual code:
- Instructions:
comp-ctxt-funcs-hcomp-func-blockscomp-block-insns
- Signature:
comp-args-maxcomp-nargs-pcomp-func-frame-sizecomp-func-has-non-localcomp-func-l-argscomp-func-l-p
- Instructions:
- Values:
comp-cstr-immcomp-cstr-imm-vld-pcomp-mvar-slotcomp-mvar-type-hint-match-p
- Relocation:
comp-ctxt-d-defaultcomp-ctxt-d-ephemeralcomp-ctxt-d-impurecomp-data-container-idxcomp-data-container-l
And these (and especially relocation) are what we will focus when reading
comp.c.
3.2. Emitting Functions
The key part of comp--compile-ctxt-to-file0 seems to be this:
comp.d_default_idx = CALL1I (comp-data-container-idx, CALL1I (comp-ctxt-d-default, Vcomp_ctxt)); comp.d_impure_idx = CALL1I (comp-data-container-idx, CALL1I (comp-ctxt-d-impure, Vcomp_ctxt)); comp.d_ephemeral_idx = CALL1I (comp-data-container-idx, CALL1I (comp-ctxt-d-ephemeral, Vcomp_ctxt)); emit_ctxt_code (); // ... struct Lisp_Hash_Table *func_h = XHASH_TABLE (CALL1I (comp-ctxt-funcs-h, Vcomp_ctxt)); DOHASH (func_h, k, function) declare_function (function); /* Compile all functions. Can't be done before because the relocation structs has to be already defined. */ DOHASH (func_h, k, function) compile_function (function);
The actual lines of code are not much, but it seems every piece somehow has
something to do with relocation. It's actually not surprising, because unlike
what we intend to do (JIT compilation, no persistent data (yet)), Emacs almost
always dump the compilation product into .eln file before actually loading
them, meaning serialization (and thus restoring, e.g., interned symbols) is a
must.
There is another thing to note: we have to grasp how Emacs loads an .eln file.
Most ideally, it might load them all from the C side, and we don't need to
provide a matching Lisp API that can potentially impact our internal designs.
But anyways, if we are to support relocation constructs, we might as well do it after implementing a fully working pdumper. And thus this post might remain incomplete for some more time.
Footnotes:
The Lisp code here uses Emacs read syntax for cyclic
representation (like #1= and #1#). #<number>= marks the sexp that follows
it, and #<number># refers to the sexp previously marked.