ASM & DESMOSCENE

eLectronic @ magaZine #0



Curso de Demos. Nivel Básico I

INTRODUCCION

Hola all!

Aquí estamos con la primera entraga del curso básico. Para seguirlo basta con haber hecho algún COM-EXE en ensamblador, saber lo que es una interrupción, los segmentos y tener nociones generales de las instrucciones. Si no es así pasaros antes por el curso de ASM de Pablo Barrón ;)

Voy a dividir este capítulo en dos, optimizaciones del 386, y las del 486/Pentium.

Otra cosa, no es lo mismo optimizar para velocidad que para tamaño, aquí voy a poner optimizaciones para velocidad.


OPTIMIZACION EN EL 386

La mayor ventaja que nos ofrece el ensamblador es su velocidad de ejecución. Los compiladores de C++ y Pascal han logrado un alto grado de optimización, pero nada automático puede sustituir al programador (aunque últimamente el WatCom est  dejando pasmado a todo el mundo).

Por ejemplo, si asignamos 0 a dos segmentos el compilador hará esto:

 
  xor   ax,ax          ;Esto está bien optimizado, es más rápido 
  mov   es,ax          ;xor que 'mov ax,0' 
  xor   ax,ax          ;Esto es lo que sobra y una persona nunca pondría.
  mov   ds,ax 

Por esta razón no queda más remedio que programar en ensamblador las partes del programa que requieran mayor velocidad. Las optimizaciones más generales son éstas:


COMPARACIONES
a.- OR y AND en vez de CMP

  lento                  rápido
  cmp  ax,0        --->  or    ax,ax 
  je   label       --->  jz    label 

  cmp  ax,bx       --->  and   ax,bx 
  je   label       --->  jz    label 

La comparación de ax con 0 es más rápida con or, y con bx es más rápida el and. Siempre que tengamos que comparar un registro y no nos importe perder su valor utilizaremos OR y AND. Tened en cuenta que no lo podemos usar siempre porque con el AND y el OR perdemos el valor que hay en AX, y con CMP no.

En el primer caso también podría ser usado para comprobar el signo:

	or   ax,ax
	js   label


b.- COMPARACIONES DE CADENAS: jcxz

Para comparar cadenas utilizaremos JCXZ:

   Comparación de cadenas
   dirección cad1 -> ES:DI
   dirección cad2 -> DS:DI
   longitud                        -> CX
   repe   cmpsb    Sigue hasta que cx = 0  o  cad1 distinto de cad2
   jcxz   label

Es decir, se cargan los punteros en ES:DI (segmento:dirección) y en DS:DI y la instrucción JCXZ saltará a label cuando cx sea 0.

El funcionamiento sería así:

- Se cargan punteros
- repe va comparando hasta que las cadenas presenten diferencias o finalicen (CX=0).
- Si las cadenas son diferentes CX no es 0, jcxz no salta.
- Si las cadenas son iguales CX=0, jcxz salta.


c.- VARIABLES DE SEGMENTO DE CODIGO MEZCLANDO C/PASCAL/ASM.

En principio se pretende usar todas las variables que el asm nos ofrece, pero en el caso de la variable bp hay un problema: el bp se utiliza para el direccionamiento de las variables locales de un procedimiento.

Tampoco se pueden usar variables globales porque en los procedimientos grÁficos DS deja de apuntar al segmento de datos para poner la dirección de inicio de pantalla o se¤alar dónde has guardado los sprites, por ejemplo.

Así que si queremos usar BP, tendremos que crear las variables dentro de los procedimientos, en el código.

Para los que estais acostumbrados al Borland (dir‚ Borland para decir Pascal y C++ a la vez) se complica el asunto un poco más, porque no deja crear variables en el código, pero se puede emplear un pequeño truco con JMP:

        Pascal                         C++
Procedure test;ensamblador;    *       void test(void){
asm                            *       asm{
   jmp   @los                  *           jmp   los
                               *
   @var1: dw 0                 *           @var1:   dw 0
   @var2: dw 0                 *           @var2:   dw 0 };
   @los:                       *       los:
     {Resto del proc}          *           //Resto del proc
End;                           *       }
Aquí en cada acceso habrá que usar word ptr o byte ptr ya que Borland identificará @var1 y @var2 como etiquetas y no como variables.



d.- TABLAS

Para utilizar operaciones matemÁticas como seno, coseno, etc, la forma mÁs rÁpida y sin necesidad de utilizar copro es el uso de tablas.

Para senos, cosenos, etc -> Utilización de array
Para hacer que se repitan los valores se utilizar  un array circular (para que pueda dar el seno de 700, por ejemplo), se hace un array de 16, 64, 256... lo que sea. El seno ser  por ejemplo cx=sen(700):


	  mov     bx,700
	  and     63,bx                   ;si es de 64 el array
	  mov     cx,array[bx]

 
Para definir las tablas se puede usar el Borland en lugar de meter a mano todos los valores:

(Supongamos que la longitud de la tabla es PER, la tabla es TABLA, la altura en la variable es AMP, y el punto 0 lo da OFFSET)


Pascal:

procedure sin_gen(var tabla:Array of word;per,amp,offset:word);
Var i:Word;
Begin
	for i:=0 to per-1 do
		tabla[i]:=round(sin(i*2*pi/per)*amp)+offset;
	End;

C++:

void sin_gen(int *tabla, int per, int amp,int offset)
{
for(int i=0;i"<"per;i++)
	tabla[i]=(int)(sin(i*2*3.1415/per)*amp)+offset;
}

Estos procs nos generaría una tabla de senos que podríamos usar cómodamente en ASM, y funcionaría mucho más rápido.


e.- MALDITO LOOP

       Lento            Rápido

     loop   label      dec   cx
                       jne   label

La orden loop ha sido abandonada por los desarroladores de los micros. Como resultado es más rápido decrementar y saltar que hacer el loop (un 10% más rápido en los 386 y hasta un 40% en 486).

Cuando se aniden bucles hay que tener en cuenta varias reglas fundamentales:

- Utilizar registros como contadores, no variables de memoria.
- Cuenta descendente (de x a 0).
- Si se requiere que el caso 0 se ejecute se usa el flag signo en vez del flag zero (js en vez de jz).

También hay que tener en cuenta que cuando se quiere hacer una comparación el 'set' es muy rápido, y hay uno para cada tipo de salto (setz->jz, setnz->jnz, sets->js...).

Por ejemplo:

 dec   cx   ;Aquí set compara CX y AL, si se cumple -> AL pasará a
 set   al   ;valer 1, sino 0.
 

g.- NUEVAS INSTRUCCIONES DEL 386

Muchas veces, acostumbrados al 286 pasamos por alto instrucciones del 386 muy útiles. Por ejemplo:

MOVER 8 BITS A 16

Para mover BL a AX

        largo            corto
     xor   bh,bh       movzx ax,bl    (con signo sería movsx)
     mov   ax,bx
 
A pesar de ser más cómodo el MOVZX es muy lento.

MULTIPLICACION RAPIDA

IMUL permite multiplicar cualquier registro, no solo ax.

     imul   dx,3      ;Multiplica dx por 3 y pone el resultado en dx
     imul   ax,dx,3   ;Multiplica dx por 3 y pone el resultado en ax
 

h.- EL ORDEN DE LAS INSTRUCCIONES

Después de efectuar un salto cada componente de la primera instrucción que se encuentre tardará un ciclo de reloj más (en modo 32 bits).

Será más rápido:


        loopme: inc esi              ; opcode
                mov [edi+1234h],eax  ; opcode, mod/rm , immediate offset
                dec ecx              ; opcode
                jne short loopme     ; opcode short_displacement
que

        loopme: mov [edi+1234h],eax
                inc esi
                dec ecx
                jne short loopme
En el primer caso se a¤ade un ciclo, en el segundo 3. Si por ejemplo este loop fuese para copiar una imagen a pantalla de 320x200 tendríamos

caso 2: 64000*3ciclos=192000
caso 1: 64000*1ciclo=64000 -> 128000 ciclos más en el segundo caso


OPTIMIZACION EN 486/PENTIUM

Primero voy a explicar cómo funcionan y luego cómo acelerarlos.

La mayor diferencia para nosotros a la hora de programar entre un 386 y un 486 es el pipeline.

La ejecución de una instrucción se realiza en varias fases:

1. FETCH: lee el código de la instrucción y los datos.
2. DECODE: interpreta la instrucción y guarda los datos en registros temporales.
3. C.ADRESS: calcula las direcciones de los operandos y lo carga en memoria.
4. EXECUTE: ejecuta la instrucción y escribe los resultados en registros internos.
5. WRITEBACK: escribe los resultados en memoria.

Cada etapa dura uno o más ciclos de reloj, así que para conseguir ejecutar los 5 pasos en un solo ciclo la CPU tiene 5 unidades internas, cada una especializada en uno de los pasos, una memoria interna superrápida para los valores de los registros (REGISTER FILE), y una unidad de control que coordina a los demás.



Al dividir el trabajo en cinco partes independientes el CPU trabaja como si fuera una fábrica en cadena:

Imagínate que se tienen que procesar las instrucciones A,B,C,D,E.

Mientras el WRITEBACK realiza la operación 5 de la instrucción A
el EXECUTION realiza la 4 de la B
el ADDRESS CALCULATE la 3 de la C
el DECODE la 2 de la D
y el FETCH la 1 de la E.

Se están realizando las 5 operaciones en un ciclo de reloj. Tras 5 ciclos se habrán completado 5 instrucciones: cada instrucción dura 1 ciclo. A esta forma de trabajar se le llama cascada o PIPELINE.

En una CPU que no fuese PIPELINE la primera unidad no podría empezar con la siguiente instrucción hasta que la quinta unidad hubiese terminado.

Cosas que la unidad de control debe comprobar para que el PIPELINE funcione correctamente:

a.- En unidad 1: FETCH

Si hay un JMP en alguna unidad de la 2 a la 4, la 1 espera a que el salto se complete (mínimo 2 ciclos) y descarta los valores de la 2 y la 3, y después comienza otra vez la instrucción. Si no se cumple el salto mantiene los datos, por eso el JMP son 3 ciclos si salta y sólo 1 si no salta.

El Pentium predice si el salto se va a llevar a cabo o no antes de ejecutarlo, así que no pierde ningún ciclo.

b.- En unidad 3: ADDRESS CALCULATION

Si en la unidad 4 se está calculando un registro que es necesario para que el 3 calcule la dirección se genera una secuencia de espera de 1 ciclo.

c.- Conflictos en el acceso a memoria

Cuando una unidad necesita el valor que se está calculando en otra unidad se genera una espera de un ciclo, que a su vez provoca una espera en las siguientes unidades en forma de cascada. A esto se le llama PIPELINE STALL o PIPELINE FLUSH. Esto se genera en cada JMP, por eso cuanto menos se salte mejor ;)

EL 486 también incluye una memoria de cach‚ interna de 4Ks que acelera los accesos a memoria, y el BURST READ que acelera la lectura para actualización del caché interno.


EL PENTIUM

Además de ser PIPELINE como el 486 es SUPERSCALAR (más de un PIPELINE) y BRANCH PREDITING (anota la posición y el resultado de los últimos saltos para intentar 'adivinar' qué instrucción cargar después del salto.

Si acierta el PIPELINE continúa sin parar. Esto hace que se aceleren los bucles anidados.

Como el PENTIUM tiene 2 PIPELINES puede ejecutar 2 instrucciones en un ciclo y tiene dos cachés de 8Ks independientes.

ACELERAR EL CODIGO EN 486/586

ADDRESS GENERATION INTERLOCK (AGI)
Cuando un registro que se usa como base o índice es adem s el destino de una instrucción anterior se genera una parada (AGI).

Por ejemplo:

        add edx, 4
        mov esi, [edx]
 
En el 486 esto sólo ocurre con instrucciones adyacentes. En el PENTIUM el AGI ocurre incluso con instrucciones separadas 3 puestos:

     add esi, 4    ;Esto lo realiza el PIPE1
     pop ebx
     dec ebx
     mov edx, [esi] ;El PIPE2 que hace ‚sta espera a que termine el PIPE1

El AGI también se provoca cada vez que se lee o escribe implícitamente en los registros:

    mov esp,ebp
    pop ebp.
es ejecutado como:
    mov esp,ebp
    [agi]          ; pop usa el ESP como puntero
    pop ebp

También se pierde un ciclo de reloj en el 486 (en el Pentium no) cuando metemos un valor inmediato y un offset indexado, porque sólo puede leer 1 valor inmediato de una vez, y un valor con un offset son 2 valores inmediatos.

                mov  spawn, 248
                mov  dword ptr [esp+4], 1
 
El 486 pierde 1 ciclo (el 586 no) cuando modifica la parte baja de un registro DWORD que es usado inmediatamente después:
                mov al,0
                mov [ebp], eax
Si se pone el ALIGN del código a 16 en el 486 y a 32 en el 586 se aceleran todas la operaciones, porque coincidir  el comienzo de la página de caché con el de tu rutina, y mientras se esté ejecutando ésta dará tiempo a que se cargue la siguiente página. Esto acelera sobre todo el 486.

En el caso del ALIGN con DATA, el ponerlo mal provocar  un retraso de 3 ciclos, tanto en el 486 como en el 586.

Las reglas a seguir para que vaya lo más rápido posible son:

- Para DWORD data -> ALIGN= 4 bytes
- Para WORD data -> ALIGN= 2 bytes en 486 y 4 bytes en 586.
- Para 64bits data-> ALIGN= 8 bytes

Acelerar las operaciones:

- Usar EAX y DS todo lo posible.

- Referirse a la pila con el ESP: es más rápido que push/pop y deja EBP libre para otros usos.

- Evitar el uso de instrucciones complejas como LEAVE, ENTER, LOOP...

- Usar operaciones de 8 bits si no se necesita más.

- Intentar no usar LEA -> puede ser usada para incluir varias instrucciones en una: LEA ECX, [EAX+EBX*4+ARRAY_NAME]. El uso de estos registros hace parase si hay alg£n otro paso que los necesita. Será más rápido si se separa cada una de las operaciones que lleva dentro:

     LEA A,[B+C*INDEX+DISPLACEMENT] es m s lento que...

     MOV X,C
     MOV A,B
     MUL X,INDEX
     ADD A,DISPLACEMENT
     ADD A,X
- Evitar el uso de MOVZX fuera de un LOOP, y usarlo dentro.

- UsarTEST para comparar con 0.

- Evitar las divisiones enteras. Normalmente un IDIV va precedido de un CDQ para dividir EDX:EAX. Es m s rápido si se copia EAX a EDX y se hace un SHR EDX,31 que hacer el CDQ. Los dos métodos duran los mismos ciclos, pero el segundo son dos instrucciones y el 586 permite ejecutar dos instrucciones a la vez. Si se sabe que el resultado va a ser positivo hacer un XOR EDX,EDX.

- Evitar multiplicaciones enteras por una constante sustituyéndola por una combinación de SHL, SUB, ADD y LEA. Como regla es mejor no usar el imul en el 486 cuando la constante sea de 6 o menos bits, o de 8 o menos bits en el 586.

El uso de LEA es mucho m s rápido:
      LEA ECX,[EDX*2]       ; multiplica EDX * 2 y lo guarda en ECX

      LEA ECX,[EDX+EDX*2]   ;
      LEA ECX,[ECX+ECX*8]   ;  ECX <--  EDX*27
      Y mejor si pones 3 instrucciones entre los dos LEAs para evitar un
      AGI en el 586
- Poner a 0 los registros con XOR.

- Evitar ENTER, en su lugar usar:

        PUSH  EBP
        mov   EBP, ESP
        sub   ESP, BYTE_COUNT
- Usar JMP SHORT en lugar de JMP siempre que sea posible.

- Evitar el Task Switching

- Evitar cargar registros de segmento y usar punteros lejanos.

OPTIMIZACION ESPECIFICA PARA EL 586

Para que el 586 consiga ejecutar 2 instrucciones en un ciclo de reloj se necesita que esas instrucciones sean 'simples', que son éstas:

(ALU quiere decir cualquier operación ALU: ADD, SUB, etc)

 1. Mov reg, reg/mem/immed
 2. mov mem, reg/imm
 3. ALU reg, reg/mem/immed
 4. ALU mem, reg/immed
 5. inc reg/mem
 6. dec reg/mem
 7. push reg/mem
 8. pop reg
 9. nop
 10. lea reg/mem
 11. Jmp/ Call / Jcc near

 
Hay muchos trucos para acelerar la ejecución del código, como poner instrucciones de cadenas con REP al principio de un LOOP, o sacar el máximo rendimiento del caché, pero las iré poniendo más adelante.

Os dejo aquí una fuentes optimizadas al máximo en tamaño para 386, es la típica firma ;)


code    segment public 'CODE'
  assume  CS:code,DS:code;,ES:code,SS:code

   org 100h
begin:
.386
    mov   al,13h
    int   10h

;Comienzo de paleta 60,40,40, color 230
mov  cl,255
mov  bx,3f27h   ;60,40

@pal0:
    mov   dx,03C8H
    mov   al,cl
    cli
    out   dx,al
    inc   dx
    mov   al,bh
    out   dx,al
    mov   al,bl
    out   dx,al
    out   dx,al
    sti
    dec   cx
    cmp   cl,69
    ja    short @pal0

    mov   bl,bh
    sar   bl,1
    dec   bh
    jns   short @pal0
;Fin de paleta

    mov   ah,9
    mov   dx,OFFSET Nombre
    int   21h

@inicio:
         mov   bh,9h;950h
         inc   dx
@l1:
         mov   ax,0a000h
         mov   es,ax
         mov   ds,ax


         mov   di,bx
         scasb
         je    short @term
         imul  di,bx,5


         sub   di,dx
         mov   ax,10
         sub   al,bh
         sal   al,3
         mov   ds:[di+42245],ax        ;42245
         mov   ds:[di+42885],ax        ;42885
         add   di,dx

         mov   cx,7
         add   di,22410
@dib:
         imul  si,si,371
         ror   si,3
         mov   ax,si
         and   ax,0010000000100000b    ;;rand de 32
         add   ax,4b4bh     ;75

         stosw
         stosw
         stosb
         sub   di,325
         loop  short @dib

@term:
         dec   bx
         jnz   short @l1


;***************************************         call  fire
         mov   bh,0fh
@loop0:
         mov   ax,ds:[bx]
         add   al,ah
         xor   ah,ah
         sal   ax,4
         mov   cl,34
         div   cl

         cmp   al,7
         jb    short  @skip


         mov   ah,al
         mov   ds:[bx-1],ax
         mov   ds:[bx-321],ax
         mov   ds:[bx-641],ax

@skip:
         inc   bx
         inc   bx
         cmp   bh,130
         jbe   short @loop0
;**********************************               End fire

      in    ax,60h          ;Leo el teclado
      cmp   al,1
      jnz   @inicio

   mov  ax,03h
   int  10h

   ret

Nombre db 'BitSpawn$'

code ENDS

   END begin

Fichero Demo compilado. (ZIP)


OPTIMIZACION By Bitspawn * Roberto Domínguez
SubNet 93:341/736.0 * FidoNet 2:341/136.0 * @70:343/14*28 Virtual Mail



©eLectronic @ magaZine - 1997.
32 BITS VISUAL CREATIONS CROUP