Sistema de trabajo. Resumen desde el otro lado

En la nueva versión de unity en 2018, finalmente agregaron oficialmente el nuevo sistema de componentes Entity, o ECS para abreviar, que en lugar del trabajo habitual con los componentes del objeto solo puede funcionar con sus datos.

Un sistema de tareas adicional le ofrece el uso de potencia informática paralela para mejorar el rendimiento de su código.

Juntos, estos dos nuevos sistemas ( ECS y Job System ) ofrecen un nuevo nivel de procesamiento de datos.

Específicamente, en este artículo no analizaré todo el sistema ECS , que actualmente está disponible como un conjunto de herramientas descargadas por separado en la unidad , sino que solo consideraré el sistema de tareas y cómo se puede usar fuera del paquete ECS .

Nuevo sistema


Inicialmente, la unidad podría haber utilizado la computación de subprocesos múltiples antes, pero todo esto tuvo que ser creado por el desarrollador por su cuenta, para resolver los problemas él mismo y sortear las dificultades. Y si antes era necesario trabajar directamente con cosas como crear subprocesos, cerrar subprocesos, grupos, sincronización, ahora todo este trabajo recaía en los hombros del motor, y el desarrollador solo necesitaba crear tareas y completarlas.

Las tareas


Para realizar cualquier cálculo en el nuevo sistema, es necesario utilizar tareas que son objetos que consisten en métodos y datos para el cálculo.

Al igual que cualquier otro dato en el sistema ECS , las tareas en el sistema de trabajo también se representan como estructuras que heredan una de las tres interfaces.

Trabajo


La interfaz de tareas más simple que contiene un método Execute que no toma nada en forma de parámetros y no devuelve nada.

La tarea en sí se ve así:

Trabajo
public struct JobStruct : IJob { public void Execute() {} } 


En el método Ejecutar , puede realizar los cálculos necesarios.

IJobParallelFor


Otra interfaz con el mismo método de ejecución , que a su vez ya acepta el índice de parámetro numérico.

IJobParallelFor
 public struct JobStruct : IJobParallelFor { public void Execute(int index) {} } 


Esta interfaz IJobParallelFor , a diferencia de la interfaz IJob , ofrece ejecutar una tarea varias veces y no solo ejecutarla, sino dividir esta ejecución en bloques que se distribuirán entre subprocesos.

No está claro No te preocupes por esto, te contaré más.

IJobParallelForTransform


Y la última interfaz especial, que, como su nombre lo indica, está diseñada para funcionar con estas transformaciones del objeto. También contiene el método Execute , con el índice del parámetro numérico y el parámetro TransformAccess donde se ubican la posición, el tamaño y la rotación de la transformación.

IJobParallelForTransform
 public struct JobStruct : IJobParallelForTransform { public void Execute(int index, TransformAccess transform) {} } 


Debido al hecho de que no puede trabajar con objetos de la unidad directamente en la tarea, esta interfaz solo puede procesar datos de transformación como una estructura TransformAccess separada.

Hecho, ahora que sabe cómo se crean las estructuras de tareas, puede proceder a practicar.

Tarea completada


Vamos a crear una tarea simple heredada de la interfaz IJob y completarla. Para esto necesitamos cualquier script MonoBehaviour simple y la estructura de la tarea misma.

Testjob
 public class TestJob : MonoBehaviour { void Start() {} } 


Ahora suelte este script en algún objeto en la escena. En el mismo script ( TestJob ) a continuación, escribiremos la estructura de la tarea y no olvidemos importar las bibliotecas necesarias.

Trabajo simple
 using Unity.Jobs; public struct SimpleJob : IJob { public void Execute() { Debug.Log("Hello parallel world!"); } } 


En el método Ejecutar , por ejemplo, imprima una línea simple en la consola.

Ahora pasemos al método Start del script TestJob , donde crearemos una instancia de la tarea y luego la ejecutaremos.

Testjob
 public class TestJob : MonoBehaviour { void Start() { SimpleJob job = new SimpleJob(); job.Schedule().Complete(); } } 


Si hiciste todo como en el ejemplo, luego de comenzar el juego recibirás un mensaje simple en la consola como en la imagen.

imagen

Lo que sucede aquí: después de llamar al método Schedule , el planificador coloca la tarea en el controlador y ahora puede completarse llamando al método Complete .

Este fue un ejemplo de una tarea que simplemente imprimió texto en la consola. Para que una tarea realice cálculos paralelos, es necesario llenarla con datos.

Datos en la tarea


Al igual que en el sistema ECS , en las tareas no hay acceso a los objetos de la unidad , no puede obtener el GameObject en la tarea y cambiar su nombre allí. Todo lo que puede hacer es transferir algunos parámetros de objeto separados a la tarea, cambiar estos parámetros y, después de completar la tarea, aplicar estos cambios nuevamente al objeto.

Existen varias limitaciones en los datos de la tarea en sí: en primer lugar, deben ser estructuras y, en segundo lugar, no deben ser tipos de datos convertibles , es decir, no puede pasar el mismo booleano o cadena a la tarea.

Trabajo simple
 public struct SimpleJob : IJob { public float a, b; public void Execute() { float result = a + b; Debug.Log(result); } } 


Y la condición principal: ¡los datos no incluidos en un contenedor solo se pueden acceder dentro de la tarea!

Contenedores


Cuando se trabaja con computación de subprocesos múltiples, es necesario intercambiar datos de alguna manera entre subprocesos. Para poder transferir datos a ellos y volver a leerlos en el sistema de tareas, para estos fines hay contenedores. Estos contenedores se presentan en forma de estructuras ordinarias y trabajo según el principio de un puente mediante el cual los datos elementales se sincronizan entre flujos.

Existen varios tipos de contenedores:
NativeArray . El tipo de contenedor más simple y más utilizado se presenta como una matriz simple con un tamaño fijo.
NativeSlice . Otro contenedor, una matriz, como se desprende de la traducción, está diseñada para cortar el NativeArray en pedazos.

Estos son los dos contenedores principales disponibles sin conectar un sistema ECS . En una versión más avanzada, hay varios tipos de contenedores.

NativeList . Es una lista regular de datos.
NativeHashMap . Un análogo de un diccionario con una clave y un valor.
NativeMultiHashMap . El mismo NativeHashMap con solo unos pocos valores bajo una clave.
NativeQueue Lista de colas de datos.

Como trabajamos sin conectar un sistema ECS , solo NativeArray y NativeSlice están disponibles para nosotros .

Antes de pasar a la parte práctica, es necesario analizar el punto más importante: la creación de instancias.

Crear contenedores


Como dije antes, estos contenedores representan un puente sobre el cual los datos se sincronizan entre hilos. El sistema de tareas abre este puente antes de comenzar a trabajar y lo cierra después de su finalización. El proceso de apertura se llama " asignación " ( asignación ) o "asignación de memoria" , el proceso de cierre se llama " liberación de recursos " ( desechar ).

Es la asignación la que determina cuánto tiempo la tarea puede usar los datos en el contenedor; en otras palabras, cuánto tiempo estará abierto el puente.

Para comprender mejor estos dos procesos, echemos un vistazo a la imagen a continuación.

imagen

La parte inferior muestra el ciclo de vida del hilo principal (hilo principal ), que se calcula en el número de cuadros; en el primer cuadro, creamos otro hilo paralelo ( hilo nuevo) que existe para un cierto número de cuadros y luego se cierra de forma segura.
En el mismo hilo nuevo llega la tarea con el contenedor.

Ahora mire la parte superior de la imagen.

imagen

La barra blanca Asignación muestra la vida útil del contenedor. En el primer cuadro, se asigna el contenedor: el puente se abre, hasta este momento el contenedor no existía, después de que se hayan completado todos los cálculos en la tarea, el contenedor se libera de la memoria y en el noveno cuadro se cierra el puente.

También en esta tira ( Asignación ) hay segmentos de tiempo ( Temp , TempJob y Presistent ), cada uno de estos segmentos muestra la vida útil estimada del contenedor.

¿Por qué se necesitan estos segmentos? El hecho es que la ejecución de una tarea por duración puede ser diferente, podemos ejecutarla directamente en el mismo método donde la creamos, o podemos extender el tiempo de ejecución de la tarea si es bastante complicada, y estos segmentos muestran qué tan urgente y cuánto tiempo la tarea puede usar los datos en el contenedor

Si aún no está claro, analizaré cada tipo de asignación utilizando un ejemplo.

Ahora podemos pasar a la parte práctica de crear contenedores, para esto volvemos al método Start del script TestJob y creamos una nueva instancia del contenedor NativeArray y no nos olvidamos de conectar las bibliotecas necesarias.

Temp


Testjob
 using Unity.Jobs; using Unity.Collections; public class TestJob : MonoBehaviour { void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); } } 


Para crear una nueva instancia de contenedor, debe especificar el tamaño y el tipo de asignación en su constructor. Este ejemplo usa el tipo Temp , ya que la tarea se realizará solo en el método Start .

Ahora inicialice exactamente la misma variable de matriz en la estructura de la tarea SimpleJob .

Trabajo simple
 public struct SimpleJob : IJob { public NativeArray<int> array; public void Execute() {} } 


Listo Ahora puede crear la tarea en sí misma y pasarle una instancia de matriz.

Inicio
 void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); SimpleJob job = new SimpleJob(); job.array = array; } 


Para ejecutar la tarea esta vez, utilizaremos su identificador JobHandle para obtenerla llamando al mismo método Schedule .

Inicio
 void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); SimpleJob job = new SimpleJob(); job.array = array; JobHandle handle = job.Schedule(); } 


Ahora puede llamar al método Completo en su identificador y verificar si la tarea se ha completado para mostrar el texto en la consola.

Inicio
 void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); SimpleJob job = new SimpleJob(); job.array = array; JobHandle handle = job.Schedule(); handle.Complete(); if (handle.IsCompleted) print(" "); } 


Si ejecuta la tarea de esta forma, luego de comenzar el juego obtendrá un error rojo que dice que no liberó el contenedor de matriz de los recursos después de que se completó la tarea.

Algo asi.

imagen

Para evitar esto, llame al método Dispose en el contenedor después de completar la tarea.

Inicio
 void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); SimpleJob job = new SimpleJob(); job.array = array; JobHandle handle = job.Schedule(); handle.Complete(); if (handle.IsCompleted) print("Complete"); array.Dispose(); } 


Entonces puede reiniciarlo de forma segura.
¡Pero la tarea no hace nada! - luego agregue un par de acciones.

Trabajo simple
 public struct SimpleJob : IJob { public NativeArray<int> array; public void Execute() { for(int i = 0; i < array.Length; i++) { array[i] = i * i; } } } 


En el método Ejecutar , multiplico el índice de cada elemento de la matriz por mí mismo y lo escribo de nuevo en la matriz de la matriz para imprimir el resultado en la consola en el método de Inicio .

Inicio
 void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); SimpleJob job = new SimpleJob(); job.array = array; JobHandle handle = job.Schedule(); handle.Complete(); if (handle.IsCompleted) print(job.array[job.array.Length - 1]); array.Dispose(); } 


¿Cuál será el resultado en la consola si imprimimos el último elemento de la matriz al cuadrado?

Así es como puede crear contenedores, ponerlos en tareas y realizar acciones en ellos.

Este fue un ejemplo utilizando el tipo de asignación Temp , lo que implica completar una tarea dentro de un marco. Este tipo se utiliza mejor cuando necesita realizar cálculos rápidamente sin cargar el subproceso principal, pero debe tener cuidado si la tarea es demasiado complicada o si habrá muchos de ellos, puede producirse una caída, en este caso es mejor usar el tipo TempJob, que analizaré más adelante.

Trabajo temporal


En este ejemplo, modificaré ligeramente la estructura de la tarea SimpleJob y la heredaré de otra interfaz IJobParallelFor .

Trabajo simple
 public struct SimpleJob : IJobParallelFor { public NativeArray<Vector2> array; public void Execute(int index) {} } 


Además, dado que la tarea durará más de un fotograma, ejecutaremos y recopilaremos los resultados de la tarea en diferentes métodos Despertar e Iniciar presentados en forma de rutina. Para hacer esto, cambie un poco la apariencia de la clase TestJob .

Testjob
 public class TestJob : MonoBehaviour { private NativeArray<Vector2> array; private JobHandle handle; void Awake() {} IEnumerator Start() {} } 


En el método Despertar , crearemos una tarea y un contenedor de vectores, y en el método Inicio , enviaremos los datos recibidos y liberaremos recursos.

Despierto
 void Awake() { this.array = new NativeArray<Vector2>(100, Allocator.TempJob); SimpleJob job = new SimpleJob(); job.array = this.array; } 


Una vez más, se crea un contenedor de matriz con el tipo de asignación TempJob , después de lo cual creamos una tarea y obtenemos su manejo llamando al método Schedule con cambios menores.

Despierto
 void Awake() { this.array = new NativeArray<Vector2>(100, Allocator.TempJob); SimpleJob job = new SimpleJob(); job.array = this.array; this.handle = job.Schedule(100, 5) } 


El primer parámetro en el método Schedule indica cuántas veces se ejecutará la tarea, aquí está el mismo número que el tamaño de la matriz de matriz .
El segundo parámetro indica cuántos bloques compartir la tarea.

¿Qué otros bloques?
Anteriormente, para completar una tarea, un subproceso simplemente llamaba al método Execute una vez, ahora es necesario llamar a este método 100 veces, por lo que el planificador divide estas 100 veces de repeticiones en bloques que distribuye entre los subprocesos para no cargar ningún subproceso separado. En el ejemplo, cien repeticiones se dividirán en 5 bloques de 20 repeticiones cada una, es decir, el programador presumiblemente distribuirá estos 5 bloques en 5 hilos, donde cada hilo llamará al método Ejecutar 20 veces. En la práctica, por supuesto, no es un hecho que el planificador haga exactamente eso, todo depende de la carga de trabajo del sistema, por lo que tal vez las 100 repeticiones sucedan en un hilo.

Ahora puede llamar al método Completo en el controlador de tareas.

Despierto
 void Awake() { this.array = new NativeArray<Vector2>(100, Allocator.TempJob); SimpleJob job = new SimpleJob(); job.array = this.array; this.handle = job.Schedule(100, 5); this.handle.Complete(); } 


En la rutina de Inicio , verificaremos la ejecución de la tarea y luego limpiaremos el contenedor.

Inicio
 IEnumerator Start() { while(this.handle.isCompleted == false){ yield return new WaitForEndOfFrame(); } this.array.Dispose(); } 


Ahora pasemos a las acciones en la tarea misma.

Trabajo simple
 public struct SimpleJob : IJobParallelFor { public NativeArray<Vector2> array; public void Execute(int index) { float x = index; float y = index; Vector2 vector = new Vector2(x * x, y * y / (y * 2)); this.array[index] = vector; } } 


Después de completar la tarea en el método de Inicio , muestre todos los elementos de la matriz en la consola.

Inicio
 IEnumerator Start() { while(this.handle.IsCompleted == false){ yield return new WaitForEndOfFrame(); } foreach(Vector2 vector in this.array) { print(vector); } this.array.Dispose(); } 


Listo, puedes correr y mirar el resultado.

Para comprender la diferencia entre IJob e IJobParallelFor, eche un vistazo a las imágenes a continuación.
Por ejemplo, IJob también puede usar un bucle simple para realizar cálculos varias veces, pero en cualquier caso, un hilo solo puede llamar al método Ejecutar una vez durante toda la tarea: así es como hacer que una persona realice cientos de las mismas acciones seguidas.

imagen

IJobParallelFor ofrece no solo ejecutar una tarea en un hilo varias veces, sino también distribuir estas repeticiones entre otros hilos.

imagen

En general, el tipo de asignación TempJob es perfecto para la mayoría de las tareas que se realizan en varios marcos.

Pero, ¿qué sucede si necesita almacenar datos incluso después de completar una tarea? ¿Qué sucede si después de recibir el resultado no necesita destruirlo de inmediato? Para esto, es necesario usar el tipo de asignación Persistente , lo que implica la liberación de recursos y luego " cuando sea necesario". .

Persistente


Volvamos a la clase TestJob y cámbiela. Ahora crearemos tareas en el método OnEnable , verificaremos su ejecución en el método Actualizar y limpiaremos los recursos en el método OnDisable .
En el ejemplo, moveremos el objeto en el método Actualizar , para calcular la trayectoria usaremos dos contenedores de vectores: inputArray en el que colocaremos la posición actual y outputArray desde donde recibiremos los resultados.

Testjob
 public class TestJob : MonoBehaviour { private NativeArray<Vector2> inputArray; private NativeArray<Vector2> outputArray; private JobHandle handle; void OnEnable() {} void Update() {} void OnDisable() {} } 


También modificaremos ligeramente la estructura de la tarea SimpleJob al heredarla de la interfaz IJob para ejecutarla una vez.

Trabajo simple
 public struct SimpleJob : IJob { public void Execute() {} } 


En la tarea misma, también traicionaremos dos contenedores de vectores, un vector de posición y un delta numérico, que moverán el objeto al objetivo.

Trabajo simple
 public struct SimpleJob : IJob { [ReadOnly] public NativeArray<Vector2> inputArray; [WriteOnly] public NativeArray<Vector2> outputArray; public Vector2 position; public float delta; public void Execute() {} } 


Los atributos ReadOnly y WriteOnly muestran las restricciones de flujo en las acciones asociadas con los datos dentro de los contenedores. ReadOnly ofrece la secuencia solo para leer datos del contenedor, el atributo WriteOnly , por el contrario, permite que la secuencia solo escriba datos en el contenedor. Si necesita realizar estas dos acciones a la vez con un contenedor, no necesita marcarlo con un atributo.

Pasemos al método OnEnable de la clase TestJob donde se inicializarán los contenedores.

Onenable
 void OnEnable() { this.inputArray = new NativeArray<Vector2>(1, Allocator.Persistent); this.outputArray = new NativeArray<Vector2>(1, Allocator.Persistent); } 


Las dimensiones de los contenedores serán únicas ya que es necesario transmitir y recibir parámetros solo una vez. El tipo de asignación será persistente .
En el método OnDisable , liberaremos los recursos de los contenedores.

Ondisable
 void OnDisable() { this.inputArray.Dispose(); this.outputArray.Dispose(); } 


Creemos un método CreateJob separado donde crearemos una tarea con su identificador y allí la llenaremos con datos.

CreateJob
 void CreateJob() { SimpleJob job = new SimpleJob(); job.delta = Time.deltaTime; Vector2 position = this.transform.position; job.position = position; Vector2 newPosition = position + Vector2.right; this.inputArray[0] = newPosition; job.inputArray = this.inputArray; job.outputArray = this.outputArray; this.handle = job.Schedule(); this.handle.Complete(); } 


En realidad, inputArray no es realmente necesario aquí, ya que es posible transferir un vector de dirección solo a la tarea, pero creo que será mejor entender por qué estos atributos ReadOnly y WriteOnly son necesarios.

En el método de actualización , comprobaremos si la tarea se ha completado, después de lo cual aplicaremos el resultado obtenido a la transformación del objeto y lo ejecutaremos nuevamente.

Actualización
 void Update() { if (this.handle.IsCompleted) { Vector2 newPosition = this.outputArray[0]; this.transform.position = newPosition; CreateJob(); } } 


Antes de comenzar, modificaremos ligeramente el método OnEnable para que la tarea se cree inmediatamente después de que se inicialicen los contenedores.

Onenable
 void OnEnable() { this.inputArray = new NativeArray<Vector2>(1, Allocator.Persistent); this.outputArray = new NativeArray<Vector2>(1, Allocator.Persistent); CreateJob(); } 


Hecho, ahora puede ir a la tarea misma y realizar los cálculos necesarios en el método Ejecutar .

Ejecutar
 public void Execute() { Vector2 newPosition = this.inputArray[0]; newPosition = Vector2.Lerp(this.position, newPosition, this.delta); this.outputArray[0] = newPosition; } 


Para ver el resultado del trabajo, puede lanzar el script TestJob en algún objeto y ejecutar el juego.

Por ejemplo, mi sprite se desplaza gradualmente hacia la derecha.

Animación
imagen

En general, el tipo de asignación Persistente es ideal para contenedores reutilizables que no necesitan ser destruidos y recreados cada vez.

Entonces, ¿qué tipo usar?
El tipo Temp se usa mejor para realizar cálculos rápidamente, pero si la tarea es demasiado compleja y grande, puede producirse una holgura.
El tipo TempJob es ideal para trabajar con objetos de unidad , por lo que puede cambiar los parámetros de los objetos y aplicarlos, por ejemplo, en el siguiente cuadro.
El tipo Persistente se puede usar cuando la velocidad no es importante para usted, pero solo necesita calcular constantemente algún tipo de datos adicionales, por ejemplo, procesar datos a través de una red o el trabajo de una IA.

Inválido y ninguno
Hay dos tipos más de asignación no válidos y ninguno , pero se necesitan más para la depuración y no participan en el trabajo.


Jobhandle


Por separado, vale la pena analizar las capacidades del manejador de tareas, porque además de verificar el proceso de ejecución de tareas, este pequeño manejador aún puede crear redes enteras de tareas a través de dependencias (aunque prefiero llamarlas más colas).

Por ejemplo, si necesita realizar dos tareas en una secuencia determinada, para esto solo necesita adjuntar el identificador de una tarea al identificador de otra.

Se ve algo como esto.

imagen

Cada identificador individual contiene inicialmente su propia tarea, pero cuando se combinan, obtenemos un nuevo identificador con dos tareas.

Inicio
 void Start() { Job jobA = new Job(); JobHandle handleA = jobA.Schedule(); Job jobB = new Job(); JobHandle handleB = jobB.Schedule(); JobHandle result = JobHandle.CombineDependecies(handleA, handleB); result.Complete(); } 


O eso.

Inicio
 void Start() { JobHandle handle; for(int i = 0; i < 10; i++) { Job job = new Job(); handle = job.Schedule(handle); } handle.Complete(); } 


La secuencia de ejecución se guarda y el planificador no iniciará la siguiente tarea hasta que esté convencido de la anterior, pero es importante recordar que la propiedad del controlador IsCompleted esperará a que se completen todas las tareas.

Conclusión


Contenedores


  1. Cuando trabaje con datos en contenedores, no olvide que se trata de estructuras, por lo que cualquier sobrescritura de datos en el contenedor no los cambia, sino que los crea de nuevo.
  2. ¿Qué sucede si establece el tipo de asignación Temp y no borra los recursos después de completar la tarea? El error
  3. ¿Puedo crear mis propios contenedores? Es posible que las unidades describan en detalle el proceso de creación de contenedores personalizados aquí, pero es mejor pensar algunas veces: ¿vale la pena, tal vez habrá suficientes contenedores normales?

Seguridad!


Datos estáticos

No intente utilizar datos estáticos en una tarea ( Aleatorio y otros), cualquier acceso a datos estáticos violará la seguridad del sistema. En realidad, en este momento puede acceder a datos estáticos, pero solo si está seguro de que no cambian durante el trabajo, es decir, son completamente estáticos y de solo lectura.

¿Cuándo usar el sistema de tareas?

Todos estos ejemplos que se dan aquí en el artículo son solo condicionales y muestran cómo trabajar con este sistema, y ​​no cuándo usarlo. El sistema de tareas se puede usar sin ECS,debe comprender que el sistema también consume recursos en el trabajo y que, por cualquier motivo, escribir tareas de inmediato, crear montones de contenedores simplemente no tiene sentido: todo empeorará aún más. Por ejemplo, volver a calcular una matriz de 10 mil elementos de tamaño no será correcto: le llevará más tiempo trabajar como programador, pero recalcular todos los polígonos de un gran terreno o incluso generarlo es la solución correcta, puede dividir el terreno en tareas y procesar cada uno en una secuencia separada.

En general, si participa constantemente en cálculos complejos en proyectos y busca constantemente nuevas oportunidades para hacer que este proceso sea menos intensivo en recursos, entonces Job SystemEsto es exactamente lo que necesitas. Si trabajas constantemente con cálculos complejos inseparables de los objetos y quieres que tu código funcione más rápido y sea compatible con la mayoría de las plataformas, entonces ECS definitivamente te ayudará con esto. Si crea proyectos solo para WebGL, entonces esto no es para usted, en este momento Job System no admite trabajar en navegadores, aunque esto no es un problema para unitecs, sino para los desarrolladores de navegadores.

Fuente con todos los ejemplos

Source: https://habr.com/ru/post/es420829/


All Articles