Combien de primitives sont nécessaires pour implémenter un système fort?

En 1992, un autre concours de programmation obscure en langage C a eu lieu . L'un des projets présentés était un petit système de fort . J'ai été frappé par le fait que la machine virtuelle était implémentée en seulement 794 octets de code C. Le reste du système de fort a été chargé à partir de la source sur le fort. Après avoir étudié le projet, l'enthousiasme initial a fait place à la déception, car l'auteur a utilisé une astuce pas tout à fait «honnête»: il a utilisé la fonction scanf () pour analyser la source enrichie. À partir de ce moment, j'ai été tourmenté par la question - combien de primitifs sont nécessaires pour implémenter un système de fort sans de telles astuces?



Après un certain temps, j'ai découvert le système de fort sod32 écrit par Lennart Benschop . Pour ce système fort, la machine virtuelle est écrite en C et l'image binaire chargée dans la machine virtuelle ne dépend pas de l'ordre des octets dans le mot système hôte. sod32 a 32 primitives, les voici:

NOOP ( --- ) Do nothing
SWAP ( x1 x2 --- x2 x1 ) Swap the two top items on the stack.
ROT ( x1 x2 x3 --- x2 x3 x1 ) Rotate the three top items on the stack.
0= ( x --- f) f is true if and only if x is 0.
NEGATE ( n1 --- -n1) Negate top number on the stack.
UM* ( u1 u2 --- ud ) Multiply two unsigned numbers, giving double result.
C@ ( c-addr --- c) Fetch character c at c-addr.
@ ( a-addr --- x) Fetch cell x at a-addr.
+ ( w1 w2 --- w3) Add the top two numbers on the stack.
AND ( x1 x2 --- x3) Bitwise and of the top two cells on the stack.
OR ( x1 x2 --- x3) Bitwise or of the top two cells on the stack.
XOR ( x1 x2 --- x3) Bitwise exclusive or of the top two cells on the stack.
U< ( u1 u2 ---- f) f is true if and only if unsigned number u1 is less than u2.
< ( n1 n2 --- f) f is true if and only if signed number n1 is less than n2.
LSHIFT ( x1 u --- x2) Shift x1 left by u bits, zeros are added to the right.
RSHIFT ( x1 u --- x2) Shift x1 right by u bits, zeros are added to the left.
UM/MOD ( ud u1 --- urem uquot) Divide the unsigned double number ud by u1, giving unsigned quotient and remainder.
+CY ( n1 n2 cy1 --- n3 cy2) Add n1 and n2 and the carry flag cy1 (must be 0 or 1) giving sum n3 and carry flag cy2. (n3 cy2 can be seen as a double number)
SCAN1 ( x d --- u) Scan x for the first 1 bit. u is the position of that bit (counted from the scan direction) and 32 if x=0. d is the scan direction, 0 is left-to-right, 1 is right-to-left.
SPECIAL ( x ---) Any of a large number of special instructions, indicated by x.
DROP ( x ---) Discard the top item on the stack.
>R ( x ---) Push x on the return stack.
C!A ( c c-addr --- c-addr) Store character c at c-addr, keep address.
!A ( x a-addr --- a-addr) Store cell x at a-addr, keep address.
DUP ( x --- x x ) Duplicate the top cell on the stack.
OVER ( x1 x2 --- x1 x2 x1) Copy the second cell of the stack.
R@ ( --- x) x is a copy of the top of the return stack.
R> ( --- x) Pop the top of the return stack and place it on the stack.
0 ( --- 0) The constant number 0.
1 ( --- 1) The constant number 1.
4 ( --- 4) The constant number 4.
LIT ( --- lit) Push literal on the stack (literal number is in-line).






J'ai réalisé que j'avais une chance de trouver une réponse à ma question et j'ai commencé à transformer les primitives en définitions de haut niveau. Je tiens à souligner tout de suite que toute cette activité a un sens purement académique. Il est peu probable qu'il soit possible d'appliquer les résultats obtenus dans la pratique en raison de la perte de performance. Au cours de mes expérimentations, j'ai suivi la taille du binaire du système fort et le temps d'exécution de la suite de tests John Hayes. J'ai construit une nouvelle image binaire avec la commande

echo 'S" extend-cross.4" INCLUDED '|./sod32 kernel.img
      
      





et a exécuté les tests comme ceci:

time ./sod32 kernel.img <<< $(echo -e 'S" tester.fr" INCLUDED\n12345\nBYE')
      
      





Dans le tableau ci-dessous, vous pouvez voir comment chaque changement a affecté la taille et les performances. Les liens de la colonne "change" mènent au commit correspondant dans le github.

le changement taille kernel.img temps d'exécution tester.fr
sod32 d'origine 10164 0 min 0,059 s
lshift, rshift 10312 0m0.071s
+, um *, um / mod 11552 0m0.123s
c @, c! a 11952 0 min 0,795 s
0 =, nier <, u < 12340 0m2.800s
laissez tomber 12784 0m5.022s
swap, pourrir, plus 13436 0m5.860s
sp @, sp!, rp @, rp!, dup 13680 0m8.696s
r @, r>,> r 14160 0m15.463s
et, ou, xor 14336 0m21.198s
0, 1, 4 15236 0 min 21 671 s
0branche 15912 0m41.765s


En conséquence, la taille de l'image binaire du système fort est passée de 10164 à 15912 (+ 57%), les performances ont chuté 708 fois (près de 3 ordres de grandeur). Peut-être que les performances pourraient être améliorées en profilant le code et en optimisant les goulots d'étranglement, mais je suis sûr que le résultat sera toujours très lent par rapport au sod32 original.



De 32 primitives plus une logique supplémentaire dans l'interpréteur interne, je me suis retrouvé avec 7: nop, nand,!, @, Um +, spécial, allumé. Dans l'interpréteur interne, il existe une logique pour l'exécution des primitives et des définitions de haut niveau (appel), ainsi qu'une logique pour compléter la définition de haut niveau (suivant). J'ai trouvé la réponse à ma question: un système fort peut être construit sur la base de 9 primitives (ou 8, si nop ne doit pas être un primitif). Je suis confus par le fait qu'il existe jusqu'à 3 primitives pour l'accès mémoire: @,! et allumé, mais je n'ai pas compris comment l'éviter. J'aurais bien pu manquer quelque chose, donc si vous savez comment vous débarrasser d'un grand nombre de primitives, veuillez écrire dans les commentaires.



All Articles