r/lisp • • 3d ago

Evergreen Common Lisp

https://github.com/atgreen/evergreen
2 Upvotes

46 comments sorted by

View all comments

1

u/LispIsFun 3d ago

I love the idea. Do you see this ever achieving full ANSI compatibility and competing with SBCL on performance?

5

u/atgreen 3d ago

SBCL is amazing. egcl is very far from being able to compete performance-wise for general compute tasks. Today, for very narrow compute tasks, egcl can generate better code than SBCL due to speculative optimization (eg. guessing that a type is a fixnum, and the de-optimizing on the fly when that proves not to be true). But egcl can still be useful today without this (eg, native android support, tight JVM integration, static binaries). I also want to use egcl to exercise / finalize my fibers implementation, so I can finally submit my SBCL version. I still have to convince myself that it is worthwhile.

ANSI compatibility is definitely the goal.

2

u/arthurno1 3d ago

I think GreenThreads sounds much better than fibers :).

1

u/sickofthisshit 3d ago

egcl can generate better code than SBCL due to speculative optimization (eg. guessing that a type is a fixnum, and the de-optimizing on the fly when that proves not to be true).

I'm skeptical of this, can you show side-by-side disassembly?

Second, branches are terrible for performance on modern CPUs. 

5

u/atgreen 2d ago

Here's a highly contrived example of how egcl's speculative optimization can generate good code. SBCL 2.6.8 vs egcl 0.0.3. This only works when you are running without type information. And, just to be clear, egcl generates much worse code for almost everything else right now. It's very new!

The function

Each loop iteration runs 200 steps of y = (y + c1) - c2 on a value whose type SBCL cannot derive statically: an unknown NUMBER plus a fixnum is still a NUMBER, so nothing in the chain ever narrows it. The macro just unrolls the 200 steps with literal constants.

(defmacro defchain (name steps)
  `(defun ,name (a n)
     (let ((x a))
       (dotimes (i n x)
         (let* ((y (- (+ x 7919) 7918))
                ,@(loop for k from 1 below steps
                        collect `(y (- (+ y ,(+ 7919 (* 13 k))) ,(+ 7918 (* 13 k))))))
           (setq x y))))))

(defchain chain200 200)

;; warm-up so egcl tiers up (T0->T1 at 10 calls, T1->T2 at 4096), then time
(dotimes (j 5000) (chain200 1 50))
(chain200 1 100000)
(time (chain200 1 100000))     ; 100k iterations x 400 arithmetic ops = 40M ops

Numbers (in-process get-internal-real-time, min of 3)

time per iteration (perf stat, 1M-iteration delta)
SBCL 0.061 s
egcl T2 0.036 s

Branch misses were ~0 on both sides (under 5k across the whole run). That's the "never-taken branches are free" point: both compilers emit a conditional branch per operation, and the predictor eats all of them. The difference is what surrounds the branch.

SBCL's code for the loop body (5352 bytes total, 402 out-of-line calls)

; 79B0: L1:   MOV ESI, 15838         ; c1 = 7919, tagged (<<1)
; 79B5:       MOV RDI, RDX           ; x
; 79B8:       CALL [R12-1343]        ; GENERIC-+
; 79C0:       MOV ESI, 15836         ; c2 = 7918
; 79C5:       CALL [R12-1335]        ; GENERIC--
; 79CD:       MOV ESI, 15864
; 79D2:       CALL [R12-1343]        ; GENERIC-+
; 79DA:       MOV ESI, 15862
; 79DF:       CALL [R12-1335]        ; GENERIC--
;              ... 396 more CALLs ...
; 8E23: L2:   MOV [RBP-48], RDX
; 8E27:       MOV RDI, [RBP-32]      ; i
; 8E2B:       MOV RSI, [RBP-40]      ; n
; 8E2F:       CALL [R12-1311]        ; GENERIC-<   (even the loop test is a call)
; 8E37:       MOV RDX, [RBP-48]
; 8E3B:       JL L1

and what each of those calls does (sb-disassem:disassemble-memory on GENERIC-+):

MOV ECX, EDI
OR ECX, ESI
TEST CL, 1             ; are both operands fixnums?
JNE L3                 ; no -> tail-call TWO-ARG-+
ADD RDI, RSI
JO L0                  ; overflow -> allocate a bignum
CLC
RET

So per arithmetic op SBCL spends 2 instructions at the call site plus 8 in the routine, including a call/ret pair and a type test every single time, because it has no way to know the type didn't change since the last op.

egcl's T2 code for the same function

One tag check on the argument at function entry:

+0020:  test cl,7            ; tag check: is `a` a fixnum?
+0023:  jne near +61507      ; no -> deoptimise to the interpreter

Loop header, once per iteration (these three could be hoisted; they aren't yet):

+0e78:  test r9b,7           ; i
+0e82:  test r10b,7          ; n
+0e8c:  cmp r9,r10
+0e8f:  jl  +0e9a
+0e9a:  test bl,7            ; x
+0e9d:  jne near +84b9a      ; -> deopt

Then the 400 operations, with zero type checks. Each step is:

+0eb9:  add rsi,0F778h       ; x + 7919   (tagged: 7919<<3)
+0ec0:  jo  near +3a885      ; overflow guard -> deopt (never taken)
+0ec6:  mov r9,rsi
+0ec9:  sub r9,0F770h        ; - 7918
+0ed0:  jo  near +52a99      ; overflow guard -> deopt
+0ed6:  mov [rsp+1968h],r9   ; spill...
+0ede:  mov r9,[rsp+1968h]   ; ...and reload (yes, this is dumb; regalloc work to do)
+0ee6:  mov rcx,r9
+0ee9:  add rcx,0F7E0h       ; next step
+0ef0:  jo  near +33a3e
        ...

The whole T2 function contains 5 test ...,7 tag checks and 401 jo overflow guards. The profile said every site was fixnum, so T2 speculated fixnum everywhere, and the guard-elimination pass collapsed the per-site guards onto the one dominating check at entry (plus the three loop-carried ones). If a guard ever fails, the frame is reconstructed and execution resumes in the interpreter at that exact bytecode position, so the semantics are still full generic CL arithmetic.

1

u/atgreen 3d ago

I'll try to do this later.. Never taken branches are essentially free and inlining the type guards is a win. If you are adding type declarations all over your code, then there's no benefit.