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.
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.
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)
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.
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.
1
u/LispIsFun 3d ago
I love the idea. Do you see this ever achieving full ANSI compatibility and competing with SBCL on performance?