java nio bytebuffer

java - Por qué el diferencial de curva de rendimiento impar entre ByteBuffer.allocate() y ByteBuffer.allocateDirect()



nio (4)

Estoy trabajando en un SocketChannel SocketChannel SocketChannel to- SocketChannel que funcionará mejor con un búfer de byte directo: de larga duración y de gran tamaño (de decenas a cientos de megabytes por conexión). Mientras FileChannel la estructura de bucle exacta con FileChannel s, algunos micro-puntos de referencia en el rendimiento ByteBuffer.allocate() vs. ByteBuffer.allocateDirect() .

Hubo una sorpresa en los resultados que realmente no puedo explicar. En el siguiente gráfico, hay un precipicio muy pronunciado en los 256 KB y 512 KB en la implementación de transferencia ByteBuffer.allocate() : ¡el rendimiento disminuye en ~ 50%! También parece haber un acantilado de rendimiento más pequeño para el ByteBuffer.allocateDirect() . (La serie% -gain ayuda a visualizar estos cambios.)

Tamaño del búfer (bytes) versus tiempo (MS)

¿Por qué el diferencial de curva de rendimiento impar entre ByteBuffer.allocate() y ByteBuffer.allocateDirect() ? ¿Qué está sucediendo exactamente detrás de la cortina?

Es muy posible que dependa del hardware y del sistema operativo, así que aquí están los detalles

  • MacBook Pro con CPU Dual-core Core 2
  • Intel X25M SSD drive
  • OSX 10.6.4

Código fuente, a pedido:

package ch.dietpizza.bench; import static java.lang.String.format; import static java.lang.System.out; import static java.nio.ByteBuffer.*; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.UnknownHostException; import java.nio.ByteBuffer; import java.nio.channels.Channels; import java.nio.channels.ReadableByteChannel; import java.nio.channels.WritableByteChannel; public class SocketChannelByteBufferExample { private static WritableByteChannel target; private static ReadableByteChannel source; private static ByteBuffer buffer; public static void main(String[] args) throws IOException, InterruptedException { long timeDirect; long normal; out.println("start"); for (int i = 512; i <= 1024 * 1024 * 64; i *= 2) { buffer = allocateDirect(i); timeDirect = copyShortest(); buffer = allocate(i); normal = copyShortest(); out.println(format("%d, %d, %d", i, normal, timeDirect)); } out.println("stop"); } private static long copyShortest() throws IOException, InterruptedException { int result = 0; for (int i = 0; i < 100; i++) { int single = copyOnce(); result = (i == 0) ? single : Math.min(result, single); } return result; } private static int copyOnce() throws IOException, InterruptedException { initialize(); long start = System.currentTimeMillis(); while (source.read(buffer)!= -1) { buffer.flip(); target.write(buffer); buffer.clear(); //pos = 0, limit = capacity } long time = System.currentTimeMillis() - start; rest(); return (int)time; } private static void initialize() throws UnknownHostException, IOException { InputStream is = new FileInputStream(new File("/Users/stu/temp/robyn.in"));//315 MB file OutputStream os = new FileOutputStream(new File("/dev/null")); target = Channels.newChannel(os); source = Channels.newChannel(is); } private static void rest() throws InterruptedException { System.gc(); Thread.sleep(200); } }


Cómo funciona ByteBuffer y por qué los Buffers directos (Byte) son los únicos realmente útiles ahora.

primero estoy un poco sorprendido de que no es de conocimiento común, pero soportarlo w / me

Los buffers de bytes directos asignan una dirección fuera del montón de Java.

Esto es de suma importancia: todas las funciones de SO (y C nativo) pueden utilizar esa dirección sin bloquear el objeto en el montón y copiar los datos. Ejemplo breve sobre la copia: para enviar cualquier información a través de Socket.getOutputStream (). Escriba (byte []) el código nativo debe "bloquear" el byte [], copiarlo fuera del montón de Java y luego llamar a la función del SO, por ejemplo send La copia se realiza en la pila (para byte más pequeño []) o malloc / free para las más grandes. DatagramSockets no son diferentes y también se copian, excepto que están limitados a 64 KB y se asignan a la pila, lo que incluso puede matar el proceso si la pila de subprocesos no es lo suficientemente grande o profunda en la recursión. nota: el bloqueo impide que JVM / GC mueva / reasigne el objeto alrededor del montón

Entonces, con la introducción de NIO, la idea fue evitar la copia y las multitudes del flujo de canalización / indirección. A menudo hay 3-4 tipos de flujos en búfer antes de que los datos lleguen a su destino. (yay Polonia iguala (!) con una hermosa toma) Al introducir los búferes directos, java podía comunicarse directamente al código nativo C sin necesidad de bloquear / copiar. Por lo tanto, la función sent puede tomar la dirección del buffer agregar la posición y el rendimiento es muy similar al nativo C. Eso es sobre el buffer directo.

El principal problema con los búferes directos: son caros de asignar y costosos de desasignar y bastante engorroso de usar, nada como byte [].

El búfer no directo no ofrece la verdadera esencia que ofrecen los búferes directos, es decir, el puente directo al sistema operativo / nativo, en lugar de que sean livianos y compartan exactamente la misma API, e incluso más, pueden wrap byte[] e incluso su respaldo array está disponible para manipulación directa, ¿qué no amar? ¡Bien, tienen que ser copiados!

Entonces, ¿cómo maneja Sun / Oracle los almacenamientos intermedios no directos ya que el sistema operativo / nativo no puede usarlos, bueno, ingenuamente? Cuando se utiliza una memoria intermedia no directa, se debe crear una parte contraria directa. La implementación es lo suficientemente inteligente como para usar ThreadLocal y almacenar en caché algunos búferes directos a través de SoftReference * para evitar el alto costo de la creación. La parte ingenua aparece al copiarlos: intenta copiar todo el búfer ( remaining() ) cada vez.

Ahora imagine: 512 KB de búfer no directo que va al búfer de socket de 64 KB, el búfer de socket no tomará más que su tamaño. Por lo tanto, la primera vez que se copien 512 KB se realizará de forma no directa a thread-local-direct, pero solo se utilizarán 64 KB. La próxima vez se copiarán 512-64 KB, pero solo se utilizarán 64 KB, y la tercera vez se copiarán 512-64 * 2 KB, pero solo se utilizarán 64 KB, y así sucesivamente ... y eso es optimista de que siempre el zócalo el búfer estará vacío por completo. Así que no solo está copiando n KB en total, sino n × n ÷ m ( n = 512, m = 16 (el espacio promedio que le queda al buffer de socket)).

La parte copiadora es una ruta común / abstracta a todo el búfer no directo, por lo que la implementación nunca conoce la capacidad objetivo. Copiar los cachés y lo que no, reduce el ancho de banda de la memoria, etc.

* Una nota sobre el almacenamiento en memoria caché de SoftReference: depende de la implementación del GC y la experiencia puede variar. El GC de Sun usa la memoria de almacenamiento dinámico libre para determinar la vida útil de SoftRefences que conduce a un comportamiento extraño cuando se liberan (la aplicación necesita asignar los objetos almacenados previamente en caché), es decir, una mayor asignación (los ByteBuffers directos toman una parte menor en el montón, por lo al menos no afectan la basura extra de la caché sino que se ven afectados)

Mi regla del pulgar: un búfer directo agrupado dimensionado con el búfer de lectura / escritura del socket. El sistema operativo nunca copia más de lo necesario.

Este micro-benchmark es en su mayoría prueba de rendimiento de memoria, el sistema operativo tendrá el archivo completamente en caché, por lo que principalmente prueba memcpy . Una vez que los almacenamientos intermedios se agoten de la memoria caché L2, la caída del rendimiento será notable. También ejecutar el benchmark de esa manera impone costos de recolección de GC crecientes y acumulados. ( rest() no recopilará los ByteBuffers de referencia suave)


Subprocesos buffers de asignación local (TLAB)

Me pregunto si el buffer de asignación local de subprocesos (TLAB) durante la prueba es de alrededor de 256K. El uso de TLABs optimiza las asignaciones del montón para que las asignaciones no directas de <= 256K sean rápidas.

Lo que se hace comúnmente es dar a cada hilo un búfer que es utilizado exclusivamente por ese hilo para hacer asignaciones. Debe utilizar alguna sincronización para asignar el búfer del montón, pero después de eso el hilo puede asignar desde el búfer sin sincronización. En el hotspot JVM nos referimos a estos como buffers de asignación local de threads (TLAB''s). Funcionan bien

Grandes asignaciones que pasan por alto el TLAB

Si mi hipótesis acerca de un 256K TLAB es correcta, entonces la información más adelante en el artículo sugiere que quizás las asignaciones> 256K para los buffers no directos más grandes omiten el TLAB. Estas asignaciones van directamente al montón, lo que requiere la sincronización de subprocesos, incurriendo en los éxitos de rendimiento.

Una asignación que no se puede realizar desde un TLAB no siempre significa que el hilo debe obtener un TLAB nuevo. Dependiendo del tamaño de la asignación y del espacio no utilizado que queda en el TLAB, la VM podría decidir simplemente realizar la asignación del montón. Esa asignación del montón requeriría sincronización pero también lo haría para obtener un nuevo TLAB. Si la asignación se considera grande (una fracción significativa del tamaño actual de TLAB), la asignación siempre se realizará fuera del montón. Esto redujo el desperdicio y manejó con gracia la asignación mucho mayor que el promedio.

Ajustando los parámetros TLAB

Esta hipótesis podría probarse utilizando la información de un artículo posterior que indica cómo ajustar el TLAB y obtener información de diagnóstico:

Para experimentar con un tamaño de TLAB específico, se deben establecer dos banderas -XX, una para definir el tamaño inicial y otra para desactivar el cambio de tamaño:

-XX:TLABSize= -XX:-ResizeTLAB

El tamaño mínimo de una etiqueta se establece con -XX: MinTLABSize, que por defecto es de 2K bytes. El tamaño máximo es el tamaño máximo de una matriz Java entera, que se utiliza para llenar la porción no asignada de un TLAB cuando se produce un barrido de GC.

Opciones de impresión de diagnóstico

-XX:+PrintTLAB

Imprime en cada barrido una línea para cada hilo (comienza con "TLAB: hilo de CC:" sin los "''s) y una línea de resumen.


Hay muchas razones por las que esto podría suceder. Sin código y / o más detalles sobre los datos, solo podemos adivinar lo que está sucediendo.

Algunas conjeturas:

  • Tal vez llegue a los bytes máximos que se pueden leer a la vez, por lo tanto, IOwaits aumenta o el consumo de memoria aumenta sin una disminución de los bucles.
  • Tal vez haya alcanzado un límite crítico de memoria, o la JVM está intentando liberar memoria antes de una nueva asignación. Intenta jugar con los parámetros -Xmx y -Xms
  • Tal vez HotSpot no pueda o no pueda optimizar, porque la cantidad de llamadas a algunos métodos es demasiado baja.
  • Tal vez hay condiciones de sistema operativo o hardware que causan este tipo de retraso
  • Tal vez la implementación de la JVM es simplemente buggy ;-)

Sospecho que estas rodillas se deben a tropezar a través de un límite de caché de CPU. La implementación "non-direct" buffer buffer () / write () implementa "cache misses" anteriormente debido a la copia adicional del búfer de memoria en comparación con la implementación de lectura () / write () del búfer "directo".