¿Cómo funciona el dispositivo de Duff?

Respuestas:

240

Hay algunas buenas explicaciones en otros lugares, pero déjenme intentarlo. (¡Esto es mucho más fácil en una pizarra!) Aquí está el ejemplo de Wikipedia con algunas anotaciones.

Digamos que está copiando 20 bytes. El control de flujo del programa para el primer paso es:

int count;                        // Set to 20
{
    int n = (count + 7) / 8;      // n is now 3.  (The "while" is going
                                  //              to be run three times.)

    switch (count % 8) {          // The remainder is 4 (20 modulo 8) so
                                  // jump to the case 4

    case 0:                       // [skipped]
             do {                 // [skipped]
                 *to = *from++;   // [skipped]
    case 7:      *to = *from++;   // [skipped]
    case 6:      *to = *from++;   // [skipped]
    case 5:      *to = *from++;   // [skipped]
    case 4:      *to = *from++;   // Start here.  Copy 1 byte  (total 1)
    case 3:      *to = *from++;   // Copy 1 byte (total 2)
    case 2:      *to = *from++;   // Copy 1 byte (total 3)
    case 1:      *to = *from++;   // Copy 1 byte (total 4)
           } while (--n > 0);     // N = 3 Reduce N by 1, then jump up
                                  //       to the "do" if it's still
    }                             //        greater than 0 (and it is)
}

Ahora, comience el segundo pase, ejecutamos solo el código indicado:

int count;                        //
{
    int n = (count + 7) / 8;      //
                                  //

    switch (count % 8) {          //
                                  //

    case 0:                       //
             do {                 // The while jumps to here.
                 *to = *from++;   // Copy 1 byte (total 5)
    case 7:      *to = *from++;   // Copy 1 byte (total 6)
    case 6:      *to = *from++;   // Copy 1 byte (total 7)
    case 5:      *to = *from++;   // Copy 1 byte (total 8)
    case 4:      *to = *from++;   // Copy 1 byte (total 9)
    case 3:      *to = *from++;   // Copy 1 byte (total 10)
    case 2:      *to = *from++;   // Copy 1 byte (total 11)
    case 1:      *to = *from++;   // Copy 1 byte (total 12)
           } while (--n > 0);     // N = 2 Reduce N by 1, then jump up
                                  //       to the "do" if it's still
    }                             //       greater than 0 (and it is)
}

Ahora, comience el tercer pase:

int count;                        //
{
    int n = (count + 7) / 8;      //
                                  //

    switch (count % 8) {          //
                                  //

    case 0:                       //
             do {                 // The while jumps to here.
                 *to = *from++;   // Copy 1 byte (total 13)
    case 7:      *to = *from++;   // Copy 1 byte (total 14)
    case 6:      *to = *from++;   // Copy 1 byte (total 15)
    case 5:      *to = *from++;   // Copy 1 byte (total 16)
    case 4:      *to = *from++;   // Copy 1 byte (total 17)
    case 3:      *to = *from++;   // Copy 1 byte (total 18)
    case 2:      *to = *from++;   // Copy 1 byte (total 19)
    case 1:      *to = *from++;   // Copy 1 byte (total 20)
           } while (--n > 0);     // N = 1  Reduce N by 1, then jump up
                                  //       to the "do" if it's still
    }                             //       greater than 0 (and it's not, so bail)
}                                 // continue here...

Ahora se copian 20 bytes.

Nota: El dispositivo Duff's original (que se muestra arriba) se copió a un dispositivo de E / S en la todirección. Por lo tanto, no fue necesario incrementar el puntero *to. Cuando copie entre dos buffers de memoria, deberá usar *to++.

Clinton Pierce
fuente
1
¿Cómo se puede omitir la cláusula case 0: y continuar buscando las otras cláusulas que están dentro del bucle do while que es el argumento de la cláusula omitida? Si la única cláusula que está fuera del do while se omite, ¿por qué el interruptor no termina allí?
Aurelius
14
No mires los frenos tan fuerte. No mires dotanto. En su lugar, mirada en el switchy la whiletan anticuada calculadas GOTOstatments o ensamblador de jmpcomandos con un desplazamiento. La switchhace algunos cálculos y luego jmps al lugar correcto. El whilehace una comprobación booleano y luego ciegamente jmpS a la derecha de donde el doestaba.
Clinton Pierce
Si esto es tan bueno, ¿por qué no todos lo usan? ¿Hay algún inconveniente?
AlphaGoku
Legibilidad de @AlphaGoku.
LF
108

La explicación en el Dr. Dobb's Journal es la mejor que encontré sobre el tema.

Este es mi momento AHA:

for (i = 0; i < len; ++i) {
    HAL_IO_PORT = *pSource++;
}

se convierte en:

int n = len / 8;
for (i = 0; i < n; ++i) {
    HAL_IO_PORT = *pSource++;
    HAL_IO_PORT = *pSource++;
    HAL_IO_PORT = *pSource++;
    HAL_IO_PORT = *pSource++;
    HAL_IO_PORT = *pSource++;
    HAL_IO_PORT = *pSource++;
    HAL_IO_PORT = *pSource++;
    HAL_IO_PORT = *pSource++;
}

n = len % 8;
for (i = 0; i < n; ++i) {
    HAL_IO_PORT = *pSource++;
}

se convierte en:

int n = (len + 8 - 1) / 8;
switch (len % 8) {
    case 0: do { HAL_IO_PORT = *pSource++;
    case 7: HAL_IO_PORT = *pSource++;
    case 6: HAL_IO_PORT = *pSource++;
    case 5: HAL_IO_PORT = *pSource++;
    case 4: HAL_IO_PORT = *pSource++;
    case 3: HAL_IO_PORT = *pSource++;
    case 2: HAL_IO_PORT = *pSource++;
    case 1: HAL_IO_PORT = *pSource++;
               } while (--n > 0);
}
Ric Tokyo
fuente
buena publicación (además tengo que encontrar una buena respuesta de usted para votar a favor;) 2 abajo, 13 para ir: stackoverflow.com/questions/359727#486543 ). Disfruta de la bonita insignia de respuesta.
VonC
13
El hecho crucial aquí, y que hizo que el dispositivo de Duff fuera incomprensible para mí por mucho tiempo, es que por una peculiaridad de C, después de la primera vez que llega al momento, salta hacia atrás y ejecuta todas las declaraciones. Por lo tanto, incluso si len%8fuera 4, ejecutará el caso 4, el caso 2, el caso 2 y el caso 1, y luego saltará hacia atrás y ejecutará todos los casos desde el siguiente ciclo en adelante. Esta es la parte que debe explicarse, la forma en que el bucle y la declaración de interruptor "interactúan".
ShreevatsaR
2
El artículo del Dr. Dobbs es bueno, sin embargo, aparte del enlace, la respuesta no agrega nada. Vea la respuesta de Rob Kennedy a continuación, que en realidad proporciona un punto importante sobre el resto del tamaño de transferencia que se maneja primero seguido de cero o más bloques de transferencia de 8 bytes. En mi opinión, esa es la clave para entender este código.
Richard Chambers
3
¿Me estoy perdiendo algo o en el segundo fragmento de código len % 8no se copiarán los bytes?
novato
Estaba atascado, olvidando que si no escribe una declaración de interrupción al final de la lista de declaraciones de un caso, C (o cualquier otro lenguaje) continuará ejecutando las declaraciones. Entonces, si se pregunta por qué el dispositivo de Duff funciona, esta es una parte crucial
goonerify
75

Hay dos cosas clave para el dispositivo de Duff. Primero, lo que sospecho es la parte más fácil de entender, el ciclo se desenrolla. Esto cambia el tamaño de código más grande por una mayor velocidad al evitar parte de la sobrecarga involucrada en verificar si el ciclo está terminado y saltar de nuevo a la parte superior del ciclo. La CPU puede funcionar más rápido cuando ejecuta código de línea recta en lugar de saltar.

El segundo aspecto es la declaración de cambio. Permite que el código salte a la mitad del ciclo la primera vez. La parte sorprendente para la mayoría de la gente es que tal cosa está permitida. Bueno, está permitido. La ejecución comienza en la etiqueta del caso calculado, y luego cae en cada declaración de asignación sucesiva, como cualquier otra declaración de cambio. Después de la última etiqueta del caso, la ejecución alcanza la parte inferior del bucle, en cuyo punto salta de nuevo a la parte superior. La parte superior del bucle está dentro de la declaración del interruptor, por lo que el interruptor ya no se vuelve a evaluar.

El bucle original se desenrolla ocho veces, por lo que el número de iteraciones se divide por ocho. Si el número de bytes a copiar no es un múltiplo de ocho, entonces quedan algunos bytes. La mayoría de los algoritmos que copian bloques de bytes a la vez manejarán los bytes restantes al final, pero el dispositivo de Duff los maneja al principio. La función calcula count % 8la declaración de cambio para calcular cuál será el resto, salta a la etiqueta del caso para esa cantidad de bytes y los copia. Luego, el bucle continúa copiando grupos de ocho bytes.

Rob Kennedy
fuente
55
Esta explicación tiene más sentido. la clave para que comprenda que el resto se copia primero y luego el resto en bloques de 8 bytes, lo cual es inusual ya que, como se mencionó la mayoría de las veces, copiaría en bloques de 8 bytes y luego el resto. hacer el resto primero es la clave para entender este algoritmo.
Richard Chambers
+1 por mencionar la loca ubicación / anidamiento de switch / while loop. Imposible imaginar que proviene de un lenguaje como Java ...
Parobay 03 de
13

El objetivo del dispositivo duffs es reducir el número de comparaciones realizadas en una implementación de memcpy estricta.

Suponga que desea copiar bytes 'contar' de a a b, el enfoque directo es hacer lo siguiente:

  do {                      
      *a = *b++;            
  } while (--count > 0);

¿Cuántas veces necesitas comparar el conteo para ver si es superior a 0? 'contar' veces.

Ahora, el dispositivo duff utiliza un desagradable efecto secundario involuntario de una caja del interruptor que le permite reducir la cantidad de comparaciones necesarias para contar / 8.

Ahora suponga que desea copiar 20 bytes usando el dispositivo duffs, ¿cuántas comparaciones necesitaría? Solo 3, ya que copia ocho bytes a la vez, excepto el último primero donde copia solo 4.

ACTUALIZADO: No tiene que hacer 8 comparaciones / declaraciones de cambio de caso, pero es razonable una compensación entre el tamaño de la función y la velocidad.

Johan Dahlin
fuente
3
Tenga en cuenta que el dispositivo de duff no está limitado a 8 duplicaciones en la declaración de cambio.
extraño
¿por qué no puedes usar en lugar de --count, count = count-8? y usar un segundo ciclo para lidiar con el resto?
hhafez
1
Hhafez, puedes usar un segundo bucle para lidiar con el resto. Pero ahora tiene el doble de código para lograr lo mismo sin aumentar la velocidad.
Rob Kennedy
Johan, lo tienes al revés. Los 4 bytes restantes se copian en la primera iteración del bucle, no en la última.
Rob Kennedy
8

Cuando lo leí por primera vez, lo auto formateé para esto

void dsend(char* to, char* from, count) {
    int n = (count + 7) / 8;
    switch (count % 8) {
        case 0: do {
                *to = *from++;
                case 7: *to = *from++;
                case 6: *to = *from++;
                case 5: *to = *from++;
                case 4: *to = *from++;
                case 3: *to = *from++;
                case 2: *to = *from++;
                case 1: *to = *from++;
            } while (--n > 0);
    }
}

y no tenía idea de lo que estaba pasando.

Tal vez no cuando se hizo esta pregunta, pero ahora Wikipedia tiene una muy buena explicación

El dispositivo es válido, C legal en virtud de dos atributos en C:

  • Especificación relajada de la declaración de cambio en la definición del lenguaje. En el momento de la invención del dispositivo, esta era la primera edición de The C Programming Language, que solo requiere que la declaración controlada del interruptor sea una declaración sintácticamente válida (compuesta) dentro de la cual pueden aparecer etiquetas de caso que prefijan cualquier subdeclaración. Junto con el hecho de que, en ausencia de una declaración de interrupción, el flujo de control pasará de una declaración controlada por una etiqueta de caso a la controlada por la siguiente, esto significa que el código especifica una sucesión de copias de conteo de direcciones de origen secuenciales al puerto de salida mapeado en memoria.
  • La capacidad de saltar legalmente a la mitad de un bucle en C.
Lazer
fuente
6

1: el dispositivo Duffs es una implicación particular del desenrollamiento del bucle. ¿Qué es el desenrollado de bucle?
Si tiene una operación para realizar N veces en un bucle, puede cambiar el tamaño del programa por velocidad ejecutando el bucle N / n veces y luego en el bucle en línea (desenrollando) el código del bucle n veces, por ejemplo, reemplazando:

for (int i=0; i<N; i++) {
    // [The loop code...] 
}

con

for (int i=0; i<N/n; i++) {
    // [The loop code...]
    // [The loop code...]
    // [The loop code...]
    ...
    // [The loop code...] // n times!
}

Lo que funciona muy bien si N% n == 0 - ¡no necesita Duff! Si eso no es cierto, entonces tienes que manejar el resto, lo cual es un dolor.

2: ¿En qué se diferencia el dispositivo Duffs de este desenrollado de bucle estándar?
El dispositivo Duffs es solo una forma inteligente de lidiar con los ciclos de bucle restantes cuando N% n! = 0. Todo el do / while ejecuta N / n número de veces según el desenrollado de bucle estándar (porque se aplica el caso 0). En la última ejecución del ciclo (la 'N / n + 1' vez), el caso se activa y saltamos al caso N% n y ejecutamos el código del ciclo el número 'restante' de veces.

Ricibob
fuente
Tengo interés en este dispositivo Duffs siguiendo esta pregunta: stackoverflow.com/questions/17192246/switch-case-weird-scoping así que pensé Id tiene un ir en clarifing Duff - no está seguro de si se trata de cualquier mejora en las respuestas existentes ...
Ricibob
3

Aunque no estoy 100% seguro de lo que estás pidiendo, aquí va ...

El problema que aborda el dispositivo de Duff es el de desenrollar el bucle (como sin duda habrás visto en el enlace Wiki que publicaste). Lo que esto básicamente equivale a una optimización de la eficiencia del tiempo de ejecución, sobre la huella de la memoria. El dispositivo de Duff se ocupa de la copia en serie, en lugar de cualquier problema antiguo, pero es un ejemplo clásico de cómo se pueden hacer optimizaciones al reducir la cantidad de veces que se necesita hacer una comparación en un bucle.

Como un ejemplo alternativo, que puede hacer que sea más fácil de entender, imagine que tiene una serie de elementos que desea recorrer, y agregue 1 a ellos cada vez ... por lo general, puede usar un ciclo for, y recorrer alrededor de 100 veces . Esto parece bastante lógico y, sin embargo, es ... sin embargo, se puede hacer una optimización desenrollando el bucle (obviamente no demasiado lejos ... o bien puede simplemente no usar el bucle).

Entonces, un ciclo for regular:

for(int i = 0; i < 100; i++)
{
    myArray[i] += 1;
}

se convierte

for(int i = 0; i < 100; i+10)
{
    myArray[i] += 1;
    myArray[i+1] += 1;
    myArray[i+2] += 1;
    myArray[i+3] += 1;
    myArray[i+4] += 1;
    myArray[i+5] += 1;
    myArray[i+6] += 1;
    myArray[i+7] += 1;
    myArray[i+8] += 1;
    myArray[i+9] += 1;
}

Lo que hace el dispositivo de Duff es implementar esta idea, en C, pero (como viste en el Wiki) con copias en serie. Lo que está viendo arriba, con el ejemplo desenrollado, son 10 comparaciones en comparación con 100 en el original, lo que equivale a una optimización menor, pero posiblemente significativa.

James B
fuente
8
Te estás perdiendo la parte clave. No se trata solo de desenrollar el bucle. La instrucción switch salta al centro del bucle. Eso es lo que hace que el dispositivo se vea tan confuso. Su bucle anterior siempre realiza un múltiplo de 10 copias, pero Duff's realiza cualquier número.
Rob Kennedy
2
Eso es cierto, pero estaba tratando de simplificar la descripción del OP. ¡Quizás no lo aclare lo suficiente! :)
James B
2

Aquí hay una explicación no detallada que es lo que siento que es el quid del dispositivo de Duff:

La cuestión es que C es básicamente una buena fachada para el lenguaje ensamblador (el ensamblaje PDP-7 para ser específico; si estudiaras eso, verías cuán llamativas son las similitudes). Y, en lenguaje ensamblador, realmente no tiene bucles, tiene etiquetas e instrucciones de ramificación condicional. Entonces, el bucle es solo una parte de la secuencia general de instrucciones con una etiqueta y una rama en alguna parte:

        instruction
label1: instruction
        instruction
        instruction
        instruction
        jump to label1  some condition

y una instrucción de cambio se ramifica / salta un poco hacia adelante:

        evaluate expression into register r
        compare r with first case value
        branch to first case label if equal
        compare r with second case value
        branch to second case label if equal
        etc....
first_case_label: 
        instruction
        instruction
second_case_label: 
        instruction
        instruction
        etc...

En el ensamblaje es fácilmente concebible cómo combinar estas dos estructuras de control, y cuando lo piensas de esa manera, su combinación en C ya no parece tan extraña.

einpoklum
fuente
1

Esta es una respuesta que publiqué en otra pregunta sobre el dispositivo de Duff que obtuvo algunos upvaotes antes de que la pregunta se cerrara por duplicado. Creo que proporciona un poco de contexto valioso aquí sobre por qué debería evitar esta construcción.

"Este es el dispositivo de Duff . Es un método de desenrollar bucles que evita tener que agregar un bucle de reparación secundario para lidiar con los momentos en que el número de iteraciones de bucle no se sabe que es un múltiplo exacto del factor de desenrollado".

Como la mayoría de las respuestas aquí parecen ser generalmente positivas al respecto, voy a resaltar las desventajas.

Con este código, un compilador tendrá dificultades para aplicar cualquier optimización al cuerpo del bucle. Si acaba de escribir el código como un bucle simple, un compilador moderno debería ser capaz de manejar el desenrollado por usted. De esta manera, mantiene la legibilidad y el rendimiento y tiene la esperanza de que se apliquen otras optimizaciones al cuerpo del bucle.

El artículo de Wikipedia al que hacen referencia otros incluso dice que cuando este 'patrón' se eliminó del código fuente Xfree86, el rendimiento mejoró realmente.

Este resultado es típico de optimizar a ciegas cualquier código que creas que podría necesitarlo. Impide que el compilador haga su trabajo correctamente, hace que su código sea menos legible y más propenso a errores y generalmente lo ralentiza. Si estaba haciendo las cosas de la manera correcta en primer lugar, es decir, escribiendo código simple, luego creando perfiles para cuellos de botella y luego optimizando, nunca pensaría en usar algo como esto. No con una CPU moderna y un compilador de todos modos.

Está bien entenderlo, pero me sorprendería si alguna vez lo usas ".

Pete Fordham
fuente
0

Solo experimentando, encontré otra variante que se lleva bien sin intercalar el interruptor y el bucle:

int n = (count + 1) / 8;
switch (count % 8)
{
    LOOP:
case 0:
    if(n-- == 0)
        break;
    putchar('.');
case 7:
    putchar('.');
case 6:
    putchar('.');
case 5:
    putchar('.');
case 4:
    putchar('.');
case 3:
    putchar('.');
case 2:
    putchar('.');
case 1:
    putchar('.');
default:
    goto LOOP;
}
Aconcagua
fuente
¿Dónde está su condición de terminación?
user2338150