¿Por qué hay tanta diferencia en el time de ejecución de echo y cat?

Responder esta pregunta me hizo hacer otra pregunta:
Pensé que los siguientes guiones hacen lo mismo y el segundo debería ser mucho más rápido, porque el primero usa un cat que necesita abrir el file una y otra vez, pero el segundo abre el file solo una vez y luego simplemente hace eco de una variable:

(Consulte la sección de actualización para ver el código correcto).

Primero:

 #!/bin/sh for j in seq 10; do cat input done >> output 

Segundo:

 #!/bin/sh i=`cat input` for j in seq 10; do echo $i done >> output 

mientras que la input es de aproximadamente 50 megabytes.

Pero cuando probé el segundo, también fue demasiado lento porque hacer eco de la variable era un process masivo. También tuve algunos problemas con el segundo script, por ejemplo, el tamaño del file de salida fue más bajo de lo esperado.

También revisé la página man de echo y cat para compararlos:

eco: muestra una línea de text

cat – concatenar files e imprimir en la salida estándar

Pero no entendí la diferencia.

Asi que:

  • ¿Por qué el gato es tan rápido y el eco es tan lento en el segundo guión?
  • ¿O es el problema con la variable i ? (porque en la página man de echo se dice que muestra "una línea de text", así que supongo que está optimizado solo para variables cortas, no para variables muy largas como i . Sin embargo, eso es solo una suposition).
  • ¿Y por qué tengo problemas cuando uso echo ?

ACTUALIZAR

Utilicé la `seq 10` seq 10 lugar de la `seq 10` seq 10 `seq 10` incorrectamente. Este es un código editado:

Primero:

 #!/bin/sh for j in `seq 10`; do cat input done >> output 

Segundo:

 #!/bin/sh i=`cat input` for j in `seq 10`; do echo $i done >> output 

(Gracias especiales a roaima .)

Sin embargo, no es el punto del problema. Incluso si el ciclo ocurre solo una vez, tengo el mismo problema: el cat funciona mucho más rápido que el echo .

Hay varias cosas para considerar aquí.

 i=`cat input` 

puede ser costoso y hay muchas variaciones entre las conchas.

Esa es una característica llamada sustitución de command. La idea es almacenar toda la salida del command less los caracteres de nueva línea en la variable i en la memory.

Para hacer eso, las shells doblan el command en una subshell y leen su salida a través de un pipe o socketpair. Usted ve mucha variación aquí. En un file de 50MiB aquí, puedo ver, por ejemplo, que bash es 6 veces más lento que ksh93 pero ligeramente más rápido que zsh y el doble de rápido que yash .

La razón principal para que bash sea ​​lento es que lee de la tubería 128 bytes a la vez (mientras que otras shells leen 4KiB o 8KiB a la vez) y es penalizado por la sobrecarga de llamadas del sistema.

zsh necesita hacer algo de postprocesamiento para escaping de los bytes NUL (otros shells se rompen en los bytes NUL), y el yash incluso más yash pesados ​​mediante el análisis de caracteres de varios bytes.

Todas las shells necesitan quitar los caracteres de nueva línea que están haciendo, que pueden estar haciendo más o less eficientemente.

Algunos pueden querer manejar bytes NUL con mayor gracia que otros y verificar su presencia.

Entonces, una vez que tenga esa gran variable en la memory, cualquier manipulación en ella generalmente implica asignar más memory y datos de afrontamiento.

Aquí, está pasando (tenía la intención de pasar) el contenido de la variable a echo .

Afortunadamente, echo está incorporado en su caparazón, de lo contrario la ejecución probablemente habría fallado con un error demasiado largo de la list arg . Incluso entonces, build la matriz de list de arguments posiblemente implique copyr el contenido de la variable.

El otro problema principal en su enfoque de sustitución de command es que está invocando el operador split + glob (olvidando citar la variable).

Para eso, las shells necesitan tratar la cadena como una cadena de caracteres (aunque algunas shells no lo hacen y tienen errores al respecto), por lo que en las configuraciones regionales UTF-8, eso significa analizar las secuencias UTF-8 (si no se hace como yash ), busca los caracteres $IFS en la cadena. Si $IFS contiene espacio, tabulación o nueva línea (que es el caso por defecto), el algorithm es aún más complejo y costoso. Entonces, las palabras resultantes de esa split deben asignarse y copyrse.

La parte glob será aún más costosa. Si alguna de esas palabras contiene caracteres glob ( * , ? , [ ), Entonces el intérprete de commands tendrá que leer el contenido de algunos directorys y hacer una coincidencia de patrones costosa (la implementación de bash , por ejemplo, es notoriamente muy mala).

Si la input contiene algo como /*/*/*/../../../*/*/*/../../../*/*/* , eso será extremadamente costoso ya que significa enumerar miles de directorys y que pueden expandirse a varios cientos de MiB.

Entonces echo generalmente hará un procesamiento adicional. Algunas implementaciones expanden \x secuencias en el argumento que recibe, lo que significa analizar el contenido y probablemente otra asignación y copy de los datos.

Por otro lado, OK, en la mayoría de shells cat no está incorporado, lo que significa bifurcar un process y ejecutarlo (para cargar el código y las bibliotecas), pero después de la primera invocación, ese código y el contenido de la input file se almacenará en caching en la memory. Por otro lado, no habrá intermediario. cat leerá grandes cantidades a la vez y lo escribirá directamente sin procesarlo, y no necesita asignar una gran cantidad de memory, solo ese búfer que reutiliza.

También significa que es mucho más confiable ya que no se obstruye con los bytes NUL y no recorta los caracteres finales de la línea nueva (y no hace split + glob, aunque se puede evitar citando la variable, y no expanda la secuencia de escape, aunque puede evitar eso usando printf lugar de echo ).

Si desea optimizarlo aún más, en lugar de invocar cat varias veces, simplemente pase la input varias veces a cat .

 yes input | head -n 100 | xargs cat 

Ejecutará 3 commands en lugar de 100.

Para hacer que la versión variable sea más confiable, necesitaría usar zsh (otras shells no pueden lidiar con los bytes NUL) y hacerlo:

 zmodload zsh/mapfile var=$mapfile[input] repeat 10 print -rn -- "$var" 

Si sabe que la input no contiene bytes NUL, entonces puede hacerlo POSIXly de manera confiable (aunque puede que no funcione donde printf no esté incorporado) con:

 i=$(cat input && echo .) || exit # add an extra .\n to avoid trimming newlines i=${i%.} # remove that trailing dot (the \n was removed by cmdsubst) n=10 while [ "$n" -gt 10 ]; do printf %s "$i" n=$((n - 1)) done 

Pero eso nunca va a ser más eficiente que utilizar cat en el bucle (a less que la input sea muy pequeña).

El problema no se trata de cat y echo , se trata de la variable de cotización olvidada $i .

En una secuencia de commands de shell similar a Bourne (excepto zsh ), al dejar las variables entre comillas, los operadores de glob+split operan en las variables.

 $var 

es en realidad:

 glob(split($var)) 

Por lo tanto, con cada iteración de bucle, todo el contenido de la input (excluir las nuevas líneas finales) se ampliará, dividirá y englobará. Todo el process requiere shell para asignar memory, analizar la cadena una y otra vez. Esa es la razón por la que obtuviste el mal performance.

Puedes citar la variable para evitar glob+split pero no te ayudará mucho, ya que cuando el shell aún necesita build el argumento de cadena grande y escanear su contenido para echo (Reemplazando el echo incorporado con externo /bin/echo te dará la list de arguments demasiado larga o sin memory depende del tamaño $i ). La mayor parte de echo implementación de echo no es compatible con POSIX, ampliará las secuencias backslash \x en los arguments que recibió.

Con cat , el intérprete de commands solo necesita generar un process de iteración de ciclo y cat hará la copy de E / S. El sistema también puede almacenar en caching el contenido del file para que el process sea más rápido.

Si llamas

 i=`cat input` 

esto permite que su process de shell crezca en 50MB hasta 200MB (dependiendo de la implementación interna de caracteres anchos). Esto puede hacer que tu caparazón sea lenta, pero este no es el problema principal.

El problema principal es que el command anterior necesita leer todo el file en la memory de shell y echo $i necesita dividir el campo en ese contenido de file en $i . Para dividir el campo, todo el text del file debe convertirse en caracteres anchos y es aquí donde se gasta la mayor parte del time.

Hice algunas testings con el caso lento y obtuve estos resultados:

  • Lo más rápido es ksh93
  • El siguiente es mi Bourne Shell (2 veces más lento que ksh93)
  • El siguiente es bash (3 veces más lento que ksh93)
  • El último es ksh88 (7 veces más lento que ksh93)

El motivo por el cual ksh93 es el más rápido parece ser que ksh93 no usa mbtowc() desde libc, sino que es una implementación propia.

Por cierto: Stephane está confundido de que el tamaño de lectura tiene cierta influencia, compilé el Bourne Shell para leer en 4096 bits de bytes en lugar de 128 bytes y obtuve el mismo performance en ambos casos.

En ambos casos, el ciclo se ejecutará solo dos veces (una para la palabra seq y una para la palabra 10 ).

Además, ambos fusionarán el espacio en blanco adyacente y dejarán espacio en blanco inicial / final, de modo que la salida no será necesariamente dos copys de la input.

primero

 #!/bin/sh for j in $(seq 10); do cat input done >> output 

Segundo

 #!/bin/sh i="$(cat input)" for j in $(seq 10); do echo "$i" done >> output 

Una razón por la cual el echo es más lento puede ser que su variable sin comillas se esté dividiendo en espacios en blanco en palabras separadas. Por 50MB eso será mucho trabajo. Cita las variables!

Sugiero que corrija estos errores y luego vuelva a evaluar sus times.


He probado esto localmente Creé un file de 50MB usando la salida de tar cf - | dd bs=1M count=50 tar cf - | dd bs=1M count=50 . También amplié los loops para que funcionen por un factor de x100, de modo que los times se escalaron a un valor razonable (agregué un bucle adicional alnetworkingedor de todo el código: for k in $(seq 100); dodone ). Aquí están los times:

 time ./1.sh real 0m5.948s user 0m0.012s sys 0m0.064s time ./2.sh real 0m5.639s user 0m4.060s sys 0m0.224s 

Como puede ver, no existe una diferencia real, pero en todo caso, la versión que contiene el echo se ejecuta marginalmente más rápido. Si elimino las comillas y ejecuto su versión rota 2, el time se duplica, mostrando que el shell debe hacer mucho más trabajo de lo que debería esperarse.

 time ./2original.sh real 0m12.498s user 0m8.645s sys 0m2.732s 

El echo está destinado a poner 1 línea en la pantalla. Lo que haces en el segundo ejemplo es que colocas el contenido del file en una variable y luego imprimes esa variable. En el primero, inmediatamente coloca el contenido en la pantalla.

cat está optimizado para este uso. echo no es. Además, poner 50Mb en una variable de entorno no es una buena idea.

No se trata de que el eco sea más rápido, se trata de lo que estás haciendo:

En un caso, está leyendo desde la input y escribiendo directamente en la salida. En otras palabras, lo que se lee desde la input a través de cat, va a la salida a través de stdout.

 input -> output 

En el otro caso, está leyendo desde la input en una variable en la memory y luego escribiendo el contenido de la variable en la salida.

 input -> variable variable -> output 

Este último será mucho más lento, especialmente si la input es de 50 MB.