неделя, 3 март 2013 г.

Колко виртуална е виртуалната машина на SBCL?

Както стана ясно в предишния пост, посредством defun се дефинира функция: на съответното име се присвоява списък от форми, които при извикването на функцията се оценяват (т.е. изпъляват) една след друга. ANSI Common Lisp стандарта не уточнява дали по време на дефиницията на функцията формите се превеждат до някакъв byte code или каквото и да било друго, стига при оценяването да се случва това, което е указано в сорс кода. С прости думи -- не става ясно дали еval действа като интерпретатор или като компилатор и ако действа като компилатор, до какъв код компилира. Всяка имплементация на стандарта е свободна да процедира както намери за добре.

Фен-бойчетата на C# и Java знаят, че техните компилатори превеждат до byte code -- някакво вътрешно представяне със специализирани инструкции, които виртуалната машина по някое време (най-често по време на изпълнението) превежда до машинен код за съответния процесор. В Microsoft средите на това му викат Common Language Runtime (CLR), а в Java средите не знам какво му викат, ама е точно толкова дебилно.  Защо eval да не създава код, който директно да се изпълява на съответната физическа машина? Знам какво ще чуя: защото не е преносим. И какво от това -- нали сорс кода, бидейки прост текст, е преносим? И ако за съответната платформа има виртуална машина, която по зададен сорс код може да създаде вътрешно представяне, защо трябва да се интересувам от това, дали вътрешното представяне на една архитектура е същото като на друга? Просто разпространявам сорс кода и всичко е ОК. Естествено, това е в разрез с Enterprise мисленето, но пък моята теза е, че именно Enterprise мисленето е в основата на всичко зло. Така че това не е довод, поне не пред мен.

Аз използвам имплементацията SBCL -- Steel Bank Common Lisp. Последните две думи (Common Lisp) са ясни -- името на диалекта, за който си говорим. Първите две (Steel Bank) са бъзик с CMU-CL, чийто fork e SBCL. Университета Carnegie Mellon University (CMU) e именован на Andrew Carnegie -- крупен стоманен (Steel) магнат в Щатите в началото на XX век, и Andrew Mellon -- водещ банкер (Bank) по същото време.

В тази имплементация нещата седят по следния начин: всяко дефинране на функция води до създаването на списък от инструкции, които могат директно да се изпълнят на съответния процесор, на който се е случило това дефиниране. Това има следния недостатък -- ако компилирам нещо върху X86_64, няма да мога да го ftp-осам на PPC машина и да го изпълня там. Но на кой му пука за този use-case така или иначе? Затова пък кода, който е генериран не отстътва по бързодействие на аналогичен код, компилиран със C компилатор. И за да не съм голословен, ще приведа следния пример:

CL-USER> (defun bahor (i j) (+ i (* 2 j)))
BAHOR

Това вече трябва да е ясно на всички, чели предишния пост. Дефинираме функция bahor, която по зададени парметри i и j връща стойност i+2*j. Наблюдавайте сега:

CL-USER> (disassemble #'bahor)
; disassembly for BAHOR
; 05C102CD:       488B55F0         MOV RDX, [RBP-16]
;      2D1:       BF04000000       MOV EDI, 4
;      2D6:       4C8D1C25B9020020 LEA R11, [#x200002B9]
;      2DE:       41FFD3           CALL R11
;      2E1:       480F42E3         CMOVB RSP, RBX
;      2E5:       488BFA           MOV RDI, RDX
;      2E8:       488B55F8         MOV RDX, [RBP-8]
;      2EC:       4C8D1C25E0010020 LEA R11, [#x200001E0]
;      2F4:       41FFD3           CALL R11
;      2F7:       480F42E3         CMOVB RSP, RBX
;      2FB:       488BE5           MOV RSP, RBP
;      2FE:       F8               CLC
;      2FF:       5D               POP RBP
;      300:       C3               RET
;      301:       CC0A             BREAK 10
;      303:       02               BYTE #X02
;      304:       18               BYTE #X18
;      305:       54               BYTE #X54
NIL

Опаааа -- какво имаме тук? Първо -- имаме стандартна функция disasseble, която изпечатва листинг на машинния код + асемблера за съответната функция (само помислете колко софтуер трябва да инсталирате, за да получите същата информация за дадена функция на C, не дай си боже на Java). И второ -- машинния код както можем да видим е съставен от инструкции за (в случая) 64-битов X86 процесор.  И понеже имам достъп и до PowerPC машина, ето как изглежда аналогичния изход там:

CL-USER> (disassemble #'bahor)
; disassembly for BAHOR
; 5119667C:       92EF0004         STW $LRA,4($CFP)
;      680:       830F0010         LWZ $A0,16($CFP)
;      684:       3B200008         ADDI $A1,$ZERO,8
;      688:       3AF30070         ADDI $LRA,$CODE,112
;      68C:       3CE00400         ADDIS $NL4,$ZERO,1024
;      690:       38E70360         ADDI $NL4,$NL4,864
;      694:       7CE803A6         MTLR $NL4
;      698:       4E800020         BLR
;      69C:       00000000         BYTE #X00, #X00, #X00, #X00
;      6A0:       00001C36         BYTE #X00, #X00, #X1C, #X36
;      6A4:       7ED0B378         MR $CSP,$OCFP
;      6A8:       60000000         NOP
;      6AC:       3A77FF90         ADDI $CODE,$LRA,-112
;      6B0:       7F19C378         MR $A1,$A0
;      6B4:       830F000C         LWZ $A0,12($CFP)
;      6B8:       3AF300A0         ADDI $LRA,$CODE,160
;      6BC:       3CA00400         ADDIS $NL2,$ZERO,1024
;      6C0:       38A50200         ADDI $NL2,$NL2,512
;      6C4:       7CA803A6         MTLR $NL2
;      6C8:       4E800020         BLR
;      6CC:       00000000         BYTE #X00, #X00, #X00, #X00
;      6D0:       00002836         BYTE #X00, #X00, #X28, #X36
;      6D4:       7ED0B378         MR $CSP,$OCFP
;      6D8:       60000000         NOP
;      6DC:       3A77FF60         ADDI $CODE,$LRA,-160
;      6E0:       806F0000         LWZ $NL0,0($CFP)
;      6E4:       82EF0004         LWZ $LRA,4($CFP)
;      6E8:       7DF07B78         MR $CSP,$CFP
;      6EC:       7C6F1B78         MR $CFP,$NL0
;      6F0:       3BF70005         ADDI $LIP,$LRA,5
;      6F4:       7FE803A6         MTLR $LIP
;      6F8:       4E800020         BLR
;      6FC:       00000000         BYTE #X00, #X00, #X00, #X00
NIL

Вижда се, че SBCL компилира/интерпретира до машинен код. Т.е. на въпроса колко виртуална е виртуалната му машина -- отговора е "много малко виртуална е".

В този момент C фанатиците доволно потриват ръце и си викат "толкова много код за едно умножение и едно събиране? -- това очевидно не е оптимално". В който момент аз почвам да обяснявам как този код е в състояние да обработи всякакви стойности за ij  и върнатия резултат, стига да се събират в паметта, независимо дали са цели числа, рационални дроби или floating point и давам следния пример:

CL-USER> (bahor 10000000000000000000000000000000000000000000000000000000000 7/19)
190000000000000000000000000000000000000000000000000000000014/19

(това между другото е числото 10 на 58-ма степен + два пъти по 7/19, което е равно на рационалното число, получено като резултат на последния ред)

Но те продължават -- "Е да де, ама аз не искам такива сложнотии, аз искам i и j да са простички цели числа, които се събират в регистри на съответния процесор; резултатa и той се събира в такъв регистър, сигурен съм! И тогава това е overkill, защото не ме интересува колко е всеобхватно, аз искам да е бързо!"

За такива случаи в Common Lisp е предвидено програмистът да може да укаже на компилатора какви всъщност са типовете на променливите. Но забележете -- само с цел оптимизация, а не промяна на семантиката! Функцията ще работи и без да й указвате какви са типовете на променливите; ако ги укажете може просто да заработи по-бързо, но крайния резултат ще е един и същ! 

И така, нека да опитаме да укажем типовете, като променим кода да изглежда така:

(defun bahor (i j)
  (declare (optimize (speed 3) (safety 0)) (fixnum i j))
  (the fixnum (+ i (* 2 j))))

Имаме един допълнителен ред, който започва с declare, в който се казват следните неща преведени на Български: компилаторе, искам да оптимизираш тази функция за бързодействие (speed 3), без да се интересуваш дали типовете на аргументите, които ти подавам, съответстват на истината (safety 0) и променливите i и j са от тип fixnum (Без да изпадам в подробности ще кажа, че съгласно стандарта, променливи от тип fixnum се събират в регистър). Другата промяна е на последния ред, в който казваме "... върнатия резултат и той ще се събере в регистър". И тогава (на x86-64):

CL-USER> (disassemble #'bahor)

; disassembly for BAHOR
; 0351C4F5:       48D1E7           SHL RDI, 1
;      4F8:       4801FA           ADD RDX, RDI
;      4FB:       48D1E2           SHL RDX, 1
;      4FE:       488BE5           MOV RSP, RBP
;      501:       F8               CLC
;      502:       5D               POP RBP
;      503:       C3               RET
NIL
CL-USER> 

а на PowerPC:


CL-USER> (disassemble #'bahor)
; disassembly for BAHOR
; 5119D20C:       54A6083C         RLWINM $NL3,$NL2,1,0,30
;       10:       7CC43214         ADD $NL3,$NL1,$NL3
;       14:       54D8103A         RLWINM $A0,$NL3,2,0,29
;       18:       7DF07B78         MR $CSP,$CFP
;       1C:       7ECFB378         MR $CFP,$OCFP
;       20:       3BF70005         ADDI $LIP,$LRA,5
;       24:       7FE803A6         MTLR $LIP
;       28:       4E800020         BLR
;       2C:       00000000         BYTE #X00, #X00, #X00, #X00
NIL

Смея да твърдя, че това е съизмеримо с кода, който генерира произволен C компилатор за подобна функция. И какво излиза -- ако искаме всеобхватност, просто пишем кода; той работи  за всякакви случаи; ако искаме бързодействие и знаем какво правим, можем да го декларираме и ще получим каквото искаме. Ето това за мен е мощна и смислена платформа -- по принцип може да работи за всички случаи, а ако съм много умен, може да го накарам да работи и бързо. А не като Java-та -- каквото и да правиш, все е бавно...

Лека нощ, драги зрители!

3 коментара:

ssge каза...

това с dynamic types май още не мога да се съглася че е достатъчно безопасно, но да видим.

Unknown каза...

Затова е (safety 0). Ако му пуснеш нещо различно от fixnum ще получиш нещо различно от верния резултат :-)

Unknown каза...

А пък ако махнеш (safety 0), тогава ще сложи код, който проверява дали верно му пускаш каквото трябва и ще хвърля ексепшъни ако не е...