1 puntos por GN⁺ 2023-10-17 | 1 comentarios | Compartir por WhatsApp
  • C encajaba bien con la abstracción de hardware en la época del PDP-11, pero en las CPU modernas la máquina abstracta de C, basada en ejecución secuencial y memoria plana, se aparta mucho del hardware real
  • Spectre y Meltdown están relacionados con el resultado de que los procesadores dependan fuertemente de la predicción de bifurcaciones, la ejecución especulativa y el paralelismo a nivel de instrucciones para ejecutar rápido el modelo secuencial de C
  • Para hacer que el código C sea rápido no basta una simple traducción a código máquina: se requiere una optimización compleja a escala de LLVM/Clang, y algunas optimizaciones pueden chocar con la semántica de C
  • Reglas como la procedencia de punteros, el padding de estructuras, los valores no inicializados y el desbordamiento de enteros con signo dificultan predecir los resultados de ejecución y también pueden derivar en vulnerabilidades de seguridad
  • El modelo que encaja mejor con el hardware moderno aprovecha muchos hilos, unidades vectoriales anchas y un modelo de memoria simple, pero la compatibilidad con el código C existente sigue siendo la mayor restricción

Por qué C parecía “de bajo nivel”

  • En un lenguaje de bajo nivel, la abstracción que ofrece el hardware y la máquina abstracta del lenguaje deberían corresponderse fácilmente
  • C podía considerarse un lenguaje de bajo nivel en el PDP-11
    • Los programas se ejecutaban de forma secuencial
    • La memoria se trataba como un espacio plano
    • Los operadores de preincremento y posincremento encajaban bien con los modos de direccionamiento del PDP-11
  • Alan Perlis definió que “un lenguaje es de bajo nivel cuando un programa exige prestar atención a cosas irrelevantes”, pero esa definición por sí sola no explica lo suficiente la “cercanía al hardware” que la gente espera de un lenguaje de bajo nivel

Las CPU modernas actúan como emuladores rápidos de PDP-11

  • La causa de fondo de Spectre y Meltdown no está solo en crear procesadores rápidos, sino también en diseños de procesador que intentaron exponer rápidamente una máquina abstracta como la del PDP-11
  • Hasta antes de C11, salvo extensiones no estándar de proveedores, el código C ofrecía en la práctica una máquina completamente secuencial, y aun después de C11 mantiene en su mayoría una máquina abstracta secuencial
  • Las CPU modernas extraen paralelismo a nivel de instrucciones (ILP) para mantener ocupadas las unidades de ejecución
    • Inspeccionan operaciones cercanas y emiten en paralelo las que son independientes
    • A cambio de permitir que los programadores escriban mayormente código secuencial, aumentan la complejidad y el consumo de energía
  • Las GPU pueden lograr alto rendimiento sin esa lógica, pero exigen programas paralelos explícitos

Spectre, Meltdown y el costo de la ejecución especulativa

  • Los procesadores Intel modernos pueden tener hasta 180 instrucciones en vuelo a la vez
  • En el código C puede considerarse que, en promedio, hay una bifurcación cada unas 7 instrucciones
    • Para llenar el pipeline en un solo hilo, hay que adivinar el destino de las siguientes 25 bifurcaciones
    • Una predicción incorrecta produce trabajo que se realiza y luego se descarta, y también desperdicia energía
  • Spectre y Meltdown pudieron explotar como canal lateral los efectos secundarios visibles de ese trabajo descartado
  • El motor de renombrado de registros de un núcleo moderno de alto rendimiento es uno de los grandes consumidores de área de die y energía
    • Mientras las instrucciones están en ejecución, es difícil apagarlo o cortarle la energía
    • Las GPU no tienen una unidad de este tipo; el paralelismo proviene de múltiples hilos

El modelo de memoria plana de C no encaja con la realidad de la caché

  • La memoria plana, núcleo de la máquina abstracta de C, no coincide con el hardware real desde hace más de 20 años
  • Los procesadores modernos suelen tener tres niveles de caché entre los registros y la memoria principal
    • Como indica su nombre, la caché está oculta al programador y no es visible para C
    • Para producir código rápido en un procesador moderno, hay que usar la caché de manera eficiente
  • Un programador de C necesita conocer no solo la máquina abstracta, sino también detalles de implementación para obtener rendimiento
    • Por ejemplo, dos valores alineados a 64 bytes pueden caer en la misma línea de caché

La complejidad del compilador necesaria para hacer rápido el código C

  • Si fuera un lenguaje de bajo nivel, debería poder transformarse fácilmente en código rápido sin un compilador complejo, pero C no es así
  • Clang y las partes relacionadas de LLVM tienen alrededor de 2 millones de líneas
    • Incluso contando solo los pases de análisis y transformación necesarios para ejecutar C rápidamente, se llega a casi 200 mil líneas, sin comentarios ni líneas en blanco
  • Al procesar grandes volúmenes de datos en C, normalmente se escribe un bucle que procesa cada elemento en secuencia
    • Para ejecutarlo de forma óptima en una CPU moderna, el compilador primero debe determinar la independencia entre iteraciones del bucle
    • La palabra clave restrict puede ofrecer la garantía de que una escritura a través de un puntero no interfiere con una lectura a través de otro puntero
  • Fortran tiene ventaja sobre C en cuanto a proporcionar esa información, y esta es una de las principales razones por las que C no ha reemplazado a Fortran en computación de alto rendimiento

La vectorización y el choque con las garantías de disposición de memoria de C

  • Si las iteraciones de un bucle son independientes, el compilador intenta vectorizar el resultado
    • Los procesadores modernos pueden obtener de 4 a 8 veces más throughput con código vectorial que con código escalar
  • Para un lenguaje de bajo nivel destinado a este tipo de procesadores, sería natural contar con tipos vectoriales nativos de longitud arbitraria
    • LLVM IR ofrece este modelo porque es más fácil dividir operaciones vectoriales grandes en operaciones pequeñas que hacer lo contrario
  • Las garantías de disposición de memoria de C chocan con la optimización
    • Las estructuras con el mismo prefijo pueden usarse de forma intercambiable
    • Los offsets de los campos de una estructura quedan expuestos en el lenguaje
    • Al compilador le resulta difícil reordenar campos o insertar padding para mejorar la vectorización
  • La capacidad de controlar finamente la disposición de las estructuras de datos puede ser una ventaja de un lenguaje de bajo nivel, pero al mismo tiempo dificulta hacer que C sea rápido

Problemas con el padding, SROA y loop unswitching

  • C exige padding al final de las estructuras para garantizar que no haya padding dentro de los arreglos
  • Como las estructuras deben permitir comparaciones independientes del tipo, como memcmp, una copia de estructura también debe conservar el padding
    • En algunos experimentos, una proporción visible del tiempo total de ejecución de ciertas cargas de trabajo se dedicó a copiar padding
  • SROA es una optimización que intenta reemplazar estructuras y arreglos de longitud fija por variables individuales
    • Permite tratar los accesos de forma independiente y eliminar operaciones cuyo resultado no se observa
    • En algunos casos elimina el padding, pero no siempre
  • Loop unswitching es una optimización que saca una condición fuera de un bucle y coloca un bucle en cada una de las dos rutas
    • Choca con la idea de que, cuando se ejecuta código de bajo nivel, el programador sabe qué código se ejecuta y cuándo
    • También puede generar problemas con los conceptos de valor no especificado y comportamiento indefinido de C

Valores no inicializados y comportamiento indefinido

  • En C, leer una variable no inicializada produce un valor no especificado, que puede ser distinto en cada lectura
  • Esta regla permite comportamientos como la reutilización diferida de páginas
    • La implementación de malloc de FreeBSD informa al sistema operativo sobre páginas que no se están usando actualmente
    • El sistema operativo puede usar la primera escritura en una página como indicio de que esa página vuelve a ser necesaria
  • Si un valor no especificado se usa para control de flujo, se convierte en comportamiento indefinido
    • Por ejemplo, ocurre cuando se usa un valor no inicializado en una condición if
  • En loop unswitching, si el bucle se ejecuta 0 veces, todo el cuerpo del bucle del código original es código muerto
    • Después del unswitching, se puede bifurcar usando una variable que quizá no fue inicializada
    • Es decir, código muerto se convierte en comportamiento indefinido
  • Se puede hacer rápido el código C, pero crear un compilador lo suficientemente inteligente requiere miles de años-persona y, a veces, violar parte de las reglas del lenguaje

Por qué C se volvió difícil de entender

  • En un lenguaje de bajo nivel, el programador debería poder entender fácilmente la correspondencia entre la máquina abstracta y la máquina física real
  • En el PDP-11, las expresiones de C se mapeaban fácilmente a una o dos instrucciones, y las variables locales y los tipos primitivos también correspondían de forma simple al hardware
  • Con el tiempo, las implementaciones de C se volvieron cada vez más complejas para mantener la ilusión de código rápido y correspondencia con el hardware
  • Una encuesta de 2015 a programadores de C, autores de compiladores y miembros del comité de estándares reveló problemas en la comprensibilidad de C
    • Tras inicializar una estructura en cero y luego establecer algunos campos, el 36% estaba seguro de que todos los bits de padding serían cero, mientras que el 29% respondió que no sabía
    • El resultado real puede variar según el compilador y el nivel de optimización

Procedencia de punteros y vulnerabilidades de seguridad

  • El modelo de BCPL era relativamente simple: los valores eran palabras, y cada palabra era dato o dirección de dato
  • El modelo de C fue diseñado para poder implementarse en diversos objetivos, incluidas arquitecturas segmentadas o máquinas virtuales con garbage collection
  • El estándar de C restringe las operaciones válidas sobre punteros para evitar problemas en esos sistemas
  • C Defect Report 260 incluyó el concepto de procedencia de punteros en la definición de puntero
    • Una implementación puede rastrear el origen de un patrón de bits
    • Puede distinguir punteros de distinta procedencia aunque sean iguales a nivel de bits
  • La palabra provenance no aparece en la especificación C11, por lo que los autores de compiladores deben decidir su significado
    • GCC y Clang difieren sobre si la procedencia se conserva al convertir un puntero a entero y luego de vuelta a puntero
  • Se han observado vulnerabilidades de seguridad en código con desbordamiento de enteros con signo y desreferenciación de punteros antes de una comprobación de null
    • Como desreferenciar un puntero null es comportamiento indefinido en C, el compilador puede asumir que un puntero ya desreferenciado no puede ser null
    • Un ejemplo es CVE-2009-1897

Imaginar procesadores que no sean para C

  • Las correcciones propuestas para Spectre y Meltdown imponen una penalización considerable de rendimiento y anulan buena parte de los avances de microarquitectura de la última década
  • En vez de hacer que el código C sea rápido, es momento de repensar modelos de programación que encajen con procesadores rápidos
  • Los chips altamente multihilo como Sun/Oracle UltraSPARC Tx no necesitan tanta caché para llenar las unidades de ejecución
    • Con suficiente paralelismo de alto nivel, pueden suspender un hilo que espera memoria y llenar las unidades de ejecución con instrucciones de otro hilo
    • El problema es que los programas C tienden a tener pocos hilos ocupados
  • ARM SVE (Scalar Vector Extensions) es un ejemplo de una mejor interfaz entre el programa y el hardware
    • Las unidades vectoriales tradicionales exponen operaciones vectoriales de tamaño fijo y esperan que el compilador adapte el algoritmo a ese tamaño
    • SVE permite que el programador describa el grado de paralelismo disponible, y el hardware lo mapea al número de unidades de ejecución
    • En C, el autovectorizador debe inferir el paralelismo a partir de la estructura del bucle, lo que resulta complejo; en cambio, en una operación map de estilo funcional, la longitud del arreglo destino es directamente el paralelismo disponible, por lo que la generación de código es simple

Un modelo de memoria más simple y programación paralela

  • En las CPU modernas, el protocolo de coherencia de caché es una de las partes más difíciles de hacer rápida y correcta
  • Gran parte de la complejidad proviene de soportar lenguajes que esperan que los datos sean compartidos y modificables
  • En una máquina abstracta al estilo Erlang, todos los objetos son locales al hilo o inmutables
    • Erlang tiene un modelo más simple, con un solo objeto modificable por hilo
    • En un sistema así, el protocolo de coherencia de caché puede dividirse en dos casos: mutable o compartido
  • Los objetos inmutables pueden simplificar la caché y abaratar varias operaciones
    • Project Maxwell de Sun Labs observó que los objetos dentro de la caché y los objetos que se asignarán en la young generation forman casi el mismo conjunto
    • Si un objeto muere antes de ser expulsado de la caché, se puede ahorrar energía al no escribirlo de vuelta en la memoria principal
    • Con objetos inmutables en el heap y una pila modificable, el garbage collector puede convertirse en una máquina de estados simple, fácil de implementar en hardware
  • Un procesador diseñado puramente para velocidad probablemente soporte muchos hilos, unidades vectoriales anchas y un modelo de memoria más simple
    • Ejecutar código C en un sistema así puede ser problemático
    • Debido a la gran cantidad de código C legacy en todo el mundo, sería difícil que tuviera éxito comercial
  • La creencia común de que la programación paralela es difícil se aplica con más precisión a la programación paralela en lenguajes con máquinas abstractas como la de C
    • Alan Kay enseñó lenguajes basados en el modelo de actores a niños, y ellos escribieron programas animados con más de 200 hilos
    • Los programadores de Erlang suelen escribir programas con miles de componentes paralelos
    • En un contexto donde las CPU multinúcleo y las GPU many-core son de uso extendido, C no se mapea bien al hardware moderno

1 comentarios

 
GN⁺ 2023-10-17
Opiniones de Hacker News
  • Si C es de bajo nivel, es al menos por la gestión manual de memoria
    Especialmente en el hardware moderno, la gestión de memoria está en el centro de la programación. Que Rust destaque la seguridad de memoria sin un recolector de basura también se debe, al final, a que la razón central de la existencia de Rust está bastante cerca de la gestión de memoria. C es rápido por la memoria, y C es inseguro en gran medida por la memoria. Una de las grandes razones por las que la computación paralela es difícil también es el acceso concurrente a la memoria. La programación funcional suele estar rodeada de conceptos matemáticos, pero en buena parte consiste en hacer que los objetos finjan ser inmutables mientras, por dentro, el compilador maneja memoria mutable
    En C, si usas un asignador, todas esas llamadas son explícitas. En el C++ de antes, new/delete y los punteros crudos también invocan asignadores de forma explícita, pero muchas cosas ocurren automáticamente en los destructores. Los punteros inteligentes del C++ moderno se parecen esencialmente a los lenguajes con recolección de basura en el sentido de que tanto la asignación como la liberación ocurren automáticamente

    • Es cierto, pero incluso en C no puedes gestionar la memoria a bajo nivel de la manera en que realmente lo hace el procesador
      No puedes indicarle al procesador en qué nivel de caché poner determinados datos, qué enviar a memoria virtual, etc. Es de más bajo nivel que Python, pero es difícil verlo como gestión de memoria de bajo nivel al estilo del C de la época del PDP-11
    • La “memoria” en sí también es una abstracción sobre un modelo mucho más complejo de memoria virtual/páginas, y la mayoría de los programadores trabaja sin entenderlo bien
      En sistemas de nivel microcontrolador o sistemas sin MMU, la historia cambia, pero ese es otro tema
      Incluso yo, como desarrollador de Rust, trabajo bajo la ilusión de que los punteros son objetos físicos reales, como direcciones de memoria. Rust, y en cierta medida C++, ponen por delante abstracciones de gestión como referencias y préstamos, pero el concepto central sigue ahí
      En realidad, el kernel del sistema operativo coloca una enorme capa entre la memoria física y el programa, y las “direcciones” y los “punteros” son más bien handles sobre los que el SO y la MMU hacen todo tipo de procesamiento
      Los “punteros crudos” tampoco son realmente crudos. Son handles a offsets dentro de una página, y las páginas reales pueden estar dispersas por distintos lados. Si uno se aleja por completo de libc y del modelo de C y pasa a un mundo de referencias puras que interactúan directamente con las páginas del subsistema de VM, una especie de “handles de objeto”, quizá se acerque más al funcionamiento real del subsistema inferior
    • La gestión de memoria de C también es, en sí misma, una abstracción. malloc y free son funciones de biblioteca
      El hardware no tiene asignación por bytes de ese tipo, así que no solo es una abstracción sobre el hardware, sino también sobre la forma en que el sistema operativo asigna memoria
      En C tampoco puedes acceder directamente a la pila. Los marcos de pila están abstraídos, y lo único que puedes usar es algo como longjmp
      Si además tomas en cuenta el comportamiento indefinido y las reglas estrictas de aliasing, tampoco tienes tanto acceso para hurgar la memoria a voluntad
    • BASIC también podía hacer gestión manual de memoria, y ocupó toda una generación en computadoras que no podían implementar ISO C por completo
    • C ni siquiera maneja de forma segura toda la aritmética de enteros. Es un lenguaje que realmente se esfuerza por incorporar lo inseguro
  • Como programador de C y autor de compiladores, para alguien que entiende C y lo usa profesionalmente, C es claramente un lenguaje de bajo nivel.
    Si buscas un lenguaje de bajo nivel, C y sus parientes son las mejores opciones.
    Si estás aprendiendo C por primera vez y quieres saber cómo usarlo como un experto, conviene ignorar este artículo. Solo puede confundirte y reducir tu capacidad de usar C de manera efectiva.

    • C es un lenguaje de bajo nivel, pero es el lenguaje de bajo nivel equivocado.
      Ofrece acceso de bajo nivel a una máquina que las máquinas reales tienen que emular con bastante esfuerzo. Los dispositivos flojos y parches que se fueron agregando con los años para acceder a las máquinas reales son elementos relativamente ajenos dentro de C.
      Dicho eso, estoy de acuerdo en que el título es retóricamente brusco. Que sea el lenguaje de bajo nivel equivocado no lo convierte en un lenguaje de alto nivel. WASM también sería “equivocado” si afirmara corresponder directamente al hardware moderno, pero eso no lo hace de alto nivel.
      El hecho de que C sea una mala correspondencia no es, por sí solo, lo frustrante. Es un lenguaje de los años 70, así que es entendible, y en muchos casos sigue siendo claramente útil. Lo más frustrante es que C todavía influye mucho en el diseño de lenguajes y tiñe con fuerza la forma en que los diseñadores de lenguajes ven el hardware. Por eso, demasiadas veces el diseño de lenguajes modernos se limita a remezclar pedazos de C, en vez de crear lenguajes que encajen bien con el hardware.
    • Hablando como miembro de WG14, C es un lenguaje de bajo nivel, pero no es un ensamblador portable.
      Si crees que el código que escribes va a tener una relación uno a uno con el ensamblador, vas a tener problemas. Si quieres ver con más profundidad cómo este tipo de cosas puede hacerte tropezar, mira https://youtu.be/w3_e9vZj7D8.
    • El autor está haciendo un juego semántico.
      El punto central del autor probablemente no sea que “C no es un buen lenguaje para programación de sistemas”. Es difícil escribir en Haskell algo equivalente a volatile int *dma_register = SCATTER_GATHER_BASE;.
      El punto del autor es que el impulso por hacer que C y otros lenguajes que “modelan una máquina de von Neumann” se ejecuten rápido volvió muy complejos a los compiladores, y que el autor insinúa que “si es de bajo nivel, debería necesitar un compilador simple”. Los procesadores creados para ejecutar ese código rápido también son muy complejos, y esa complejidad tiene un costo.
      En muchos sentidos, este es un artículo que llama a un cambio de modelo de programación, y pone a las GPU como ejemplo de lo que es posible cuando se crean en conjunto un “nuevo modelo de programación” y “silicio que lo soporte”.
    • “Bajo nivel” es una expresión con varios significados.
      El significado original está más cerca del que se usa en el artículo. Un lenguaje de bajo nivel no es portable y está ligado al hardware en el que se ejecuta, mientras que un lenguaje de alto nivel puede apuntar a varias plataformas. Con esta definición, C es claramente un lenguaje de alto nivel.
      Más que decir que el autor está haciendo un juego de palabras, mi queja es que aferrarse a terminología vieja termina enturbiando la comprensión. La clasificación por “generaciones” suele ser más explicativa.
      La primera generación es el lenguaje máquina, la segunda es el ensamblador, la tercera son los lenguajes de propósito general y la cuarta son los lenguajes específicos de dominio.
      La distinción entre tercera y cuarta generación a veces se vuelve difusa, y en los años 80 y 90 también se habló de una quinta generación que al final no terminó de consolidarse. Aun así, considero que SQL, HyperCard y Mathematica son ejemplos bastante claros de lenguajes de cuarta generación.
      Lo bueno de este enfoque es que divide los lenguajes según diferencias relativamente claras sobre cuándo se usan. Después, “alto nivel/bajo nivel” puede usarse como término relativo. Mientras más alto nivel es un lenguaje, más tiende a abstraer los detalles de lo que la computadora realmente hace. Aun así se mantiene la idea de que los lenguajes de generaciones más altas son, en general, de más alto nivel, y lo único que se pierde son discusiones tontas sobre una línea divisoria completamente arbitraria y, francamente, inútil.
      Con este enfoque, también es interesante que .NET IL, WebAssembly y el bytecode de Java puedan verse como lenguajes de segunda generación de muy alto nivel. Y Forth es un lenguaje de tercera generación. Si Chuck quiere pelear, que venga.
    • Este artículo parece estar muy por encima del tedio del trabajo cotidiano.
      No trata de cómo usar un martillo, sino más bien de preguntarse si la forma en que usamos el martillo para todo, es decir, el diseño de C, nos está limitando.
  • No estoy de acuerdo con la afirmación del autor de que el conjunto de instrucciones de la CPU debería exponer más la implementación de la CPU.
    Ya se intentó en el pasado y fracasó a largo plazo. Un ejemplo son las ranuras de retardo de salto en algunos procesadores RISC diseñados a fines de los 80 y principios de los 90, como MIPS y SuperH. Para quienes no conocen el concepto: significa que la instrucción que sigue a una instrucción de salto se ejecuta sin importar si el salto se toma o no.
    A corto plazo, esto permitía hacer procesadores más simples y baratos, dejando en manos del programador la tarea de evitar una detención del pipeline después de un salto. Pero con el tiempo, el diseño de procesadores y los pipelines se volvieron más complejos, y una sola instrucción dejó de ser suficiente para cubrir el retardo de salto. Al final se convirtió en un legado que los procesadores futuros tenían que manejar por compatibilidad, y complicó más la predicción de saltos y la lógica del pipeline.

    • No creo que el autor esté diciendo “expongan detalles de implementación al azar”.
      Exponer los detalles equivocados obviamente es malo. Lo que dice es que el modelo de C tiene limitaciones importantes en el mundo de las CPU modernas.
    • Como contraargumento, controlar explícitamente el prefetching o proporcionar un “motor” adicional que lleve los datos a la caché con el patrón deseado es especialmente ventajoso para aplicaciones en tiempo real y sensibles a la latencia.
      Escuché una presentación de desarrolladores que usaron un subsistema de ese tipo en cierto procesador. Decían que, sin usarlo, gastaban el 95% de la ventana de tiempo solo copiando datos, pero que al pedir datos por adelantado con ese motor, la obtención de datos tomaba solo el 10% de la ventana de tiempo, y podían terminar el trabajo deseado dentro de aproximadamente el 50% de la ventana total, dejando mucho tiempo para funciones adicionales y mejoras.
      Si x86 hubiera tenido una función así, la habría usado durante mi doctorado para pedir por adelantado los datos de matrices a los que iba a acceder. El patrón que uso no es lineal, pero está bien definido. Ahora, para acelerar más ese código, tendría que reorganizar las matrices para que le gusten al prefetcher y refactorizar toda la base de código de arriba abajo.
    • ¿Itanium no era algo parecido? Era un enfoque en el que el compilador asumía la carga de las bifurcaciones.
    • Las ranuras de retardo de salto son el único ejemplo que conozco. ¿Hay otros?
      Claro que se puede diseñar mal si uno quiere, pero me pregunto cuántos casos históricos hay como para generalizar.
  • Considero que de bajo a alto nivel no es una dicotomía, sino un espectro.
    Se puede decir que C está dentro del tercio inferior entre los lenguajes, y expone muchos elementos primitivos de la máquina, como la gestión de memoria y de hilos. Aunque no sea tan bajo como ensamblador, está por debajo de Java o Go, y definitivamente lejos de Python o JavaScript.

    • De hecho, el estándar de C deja como indefinida la mayor parte de lo que realmente depende de la máquina, y deja muy poco como definido por la implementación.
      Además, C se adapta bastante mal a plataformas que usan memoria segmentada o direcciones no planas. Hay indicios de que esas cosas podrían volver a ponerse de moda, y la enorme difusión de C es un obstáculo realmente grande para eso.
    • Pregunta honesta: ¿qué lenguaje hay entre C y ensamblador? Si existe, nunca oí hablar de él.
      Por eso mi modelo mental siempre fue: “C es el nivel más bajo al que se puede bajar antes de darle instrucciones directamente al procesador”.
    • Ya en 1990 se lo explicaba así:
      “C no se comporta como un lenguaje típico de ‘alto nivel’. Esto se debe a que ofrece varias capacidades más comúnmente asociadas con lenguajes de ‘bajo nivel’, como el lenguaje ensamblador. Entre ellas se incluyen la capacidad de escribir y leer datos en direcciones de memoria específicas, la capacidad de realizar operaciones sobre el contenido de ubicaciones de memoria, e instrucciones para incrementar y decrementar variables enteras … Por lo tanto, C le ofrece al programador la flexibilidad y eficiencia de trabajar a bajo nivel, al tiempo que también proporciona las ventajas de las tareas de alto nivel típicas de los lenguajes informáticos actuales, como estructuras de datos más avanzadas y control del flujo del programa. Por esta razón, a veces se describe a C como un ‘lenguaje de bajo nivel de alto nivel’ o un ‘lenguaje de alto nivel de bajo nivel’.” - https://archive.org/details/computerprogramm0000ford/page/13...
    • Es difícil esperar precisión de un texto cuyo título es “C no es un lenguaje de bajo nivel”.
  • La frase al final del artículo, “en el desarrollo de software existe el mito común de que la programación paralela es difícil”, es engañosa.
    El autor sí plantea situaciones concretas en las que no es difícil, pero si la pregunta se aplica en general, la programación paralela es difícil, y no es un mito común.
    ¿La programación paralela es difícil? Si se pregunta sin condiciones más detalladas, sí. Es mucho más difícil conceptualizar que las instrucciones del código se ejecuten simultáneamente que una por una en secuencia.

    • Al programar (map inc [0 1 2 3]), ¿de verdad es distinta la dificultad de conceptualizar que la función inc se ejecute secuencialmente para cada elemento o que se ejecute en paralelo?
      Creo que la dificultad de la programación paralela no es tanto intrínseca, sino que tiene más que ver con dos cosas.
      Primero, los lenguajes por lo general toman la ejecución secuencial como predeterminada, así que para hacer asincronía hay que introducir elementos primitivos adicionales para el programador.
      Segundo, hay que saber cuándo conviene usar la programación paralela de manera efectiva.
      Si tienes una lista o un stream de elementos independientes que solo requieren cálculos independientes, la programación paralela es intuitiva.
      Donde la gente se traba es cuando intenta meter asincronía a la fuerza donde no hace falta, es decir, donde el rendimiento es igual o peor que con ejecución secuencial, o cuando en realidad los cálculos dependen unos de otros y al introducir asincronía se rompe el comportamiento.
    • No creo que eso sea correcto. Pensar en operaciones matriciales no es complicado, y definir cómo debe comportarse un agente individual en un entorno tampoco lo es.
      Cuando dices “sin detalles adicionales ni concreción”, en realidad estás usando como marco predeterminado la visión del mundo de C/la familia de C.
      El punto del autor es que la programación secuencial es solo un tipo de programación simple, no el único, y que no se adapta fácilmente al hardware moderno.
    • Estoy de acuerdo. En el procesamiento paralelo, el espacio de estados posible es mucho más grande, y por lo tanto es más complejo, y por eso más difícil.
      El hecho de que Erlang exista y que la gente lo use con éxito no significa que algo más difícil no sea difícil.
    • La programación concurrente, es decir, hacer varias cosas distintas al mismo tiempo, es difícil.
      Implementar algoritmos paralelos con infraestructura de programación concurrente, como procesos o hilos, también es difícil. Pero la programación paralela, es decir, hacer que muchos elementos de procesamiento trabajen juntos en lo mismo, es mucho más fácil si se cuenta con la abstracción correcta.
    • No creo que lo difícil sea conceptualizar que las instrucciones se ejecuten en paralelo en sí, sino coordinar de forma eficiente y correcta esas subtareas paralelas.
      Aunque algunos casos de uso, como la multiplicación de matrices, son excepciones.
  • El artículo acierta en que las computadoras no son PDP-11 rápidos, pero se equivoca al decir que eso tenga que ver con C.
    Por ejemplo, aparece la frase: “Otro elemento clave del modelo de memoria de la máquina abstracta de C es la memoria plana. Esto no es cierto desde hace más de 20 años”.
    Esto no tiene que ver con C. El hardware impone esta abstracción. Y menos mal que lo hace. Si no, los programas se detendrían al moverlos a una máquina con una caché distinta.

    • El artículo sostiene que una razón importante por la que el hardware insiste en esta abstracción es el dominio de C.
    • Así es. Muchos de los elementos que, según el criterio de este artículo, hacen que C no sea de bajo nivel ya existían en los mainframes de IBM décadas antes que x86.
      Ejemplos de eso son las estructuras de memoria jerárquicas que fingen ser RAM plana, CPU mucho más grandes de lo que sugiere el conjunto de instrucciones, con ejecución fuera de orden y especulativa, y compiladores optimizadores que separan aún más el programa escrito de la ejecución real.
      IBM ya trabajaba en estas cosas en los años 70, mucho antes del ascenso de C. Es válido criticar este modelo y buscar alternativas, pero no es justo culpar a C.
    • La memoria plana es mala para el rendimiento. En especial la memoria plana con coherencia de caché. Para los programadores es cómoda.
  • Este artículo ya tiene 5 años, y aunque la premisa de que las computadoras, en términos de arquitectura, ya no se parecen mucho a la PDP-11 se volvió incluso más cierta, la conclusión de “imaginemos un procesador que no sea de C” parece menos contundente
    Estamos viendo una separación marcada entre el código lineal y el código altamente paralelo, y eso ya ocurría en 2018. El ejemplo más claro es el auge de Python en el aprendizaje automático y el cómputo científico. Cuando el rendimiento no es la prioridad principal, sigue siendo muy cómodo escribir con un estilo de un solo hilo y un modelo de memoria plano
    Cuando el rendimiento se vuelve importante, tiene sentido pasar a lenguajes más adecuados para la programación paralela. Ahí entran el lenguaje de grafos de cómputo de cosas como Pytorch, otros conjuntos de primitivas sobre CUDA, o lenguajes más experimentales como Futhark. El código crítico para el rendimiento siempre ha tenido lenguajes específicos de dominio, y parece que estos se están volviendo más comunes, no menos. El hardware también se está construyendo en esa dirección. Algunos ejemplos son la combinación CPU+GPU común en las PC de escritorio, las extensiones vectoriales de x86 con primitivas que en la práctica forman su propio DSL, y cosas como el M1, que integran una GPU junto a la CPU para que ambas tengan acceso rápido a la misma memoria del sistema
    En otras palabras, quizá lo realmente anticuado no sea C, sino la idea de un lenguaje de propósito general que sirva igual de bien para todo tipo de trabajo

  • Si, por la sofisticación de las CPU modernas, C ya no es un lenguaje “de bajo nivel”, entonces la misma lógica también aplica al lenguaje ensamblador
    Porque cosas como la ejecución fuera de orden y el renombrado de registros también aplican al ensamblador
    La sofisticación que han alcanzado los compiladores en las últimas décadas también refuerza este argumento. Incluso el ensamblador generado por un compilador de C, es decir, el código objeto, puede salir distinto de lo esperado por optimizaciones como sacar código fuera de los bucles o eliminar subexpresiones comunes
    Aun así, creo que la idea de llamar a C un lenguaje “de bajo nivel” sigue siendo una etiqueta útil. Si no, habría que retirar el término por completo

    • El ensamblador es un poco perezoso, se expande con macros, y hasta las direcciones de memoria de la computadora son un concepto inventado
      Es cierto que es una abstracción sobre una computadora real, pero lo es mucho menos que lo que C construye sobre su modelo de computadora virtual. El ensamblador actual está a un nivel similar al de C cuando C fue creado. El C actual es tan de alto nivel que no ofrece funciones que no puedan obtenerse con lenguajes mejores y más modernos
      Dicho eso, estoy de acuerdo en que hoy en día los nombres “bajo nivel” y “alto nivel” no son muy útiles
  • El texto parece desarrollar dos líneas de argumentación que son difíciles de conciliar entre sí
    La primera es la afirmación de que C no es un lenguaje de bajo nivel, con ejemplos como el padding de estructuras y que el desbordamiento de enteros con signo sea comportamiento indefinido. Esa parte se entiende, y parece constructiva en la medida en que propone características de lenguaje para un hipotético lenguaje “realmente de bajo nivel”
    La segunda es la afirmación de que, por el dominio de C, los diseñadores de CPU tuvieron que esforzarse para crear algo que ejecutara C de forma natural. Ahí aparecen ejemplos como el renombrado de registros, la memoria plana y el caché. Ese argumento también se entiende, pero no me queda claro cómo se conecta con el primer argumento y con el título del artículo. Si se toma literalmente, parece implicar que en el hardware moderno es imposible crear un lenguaje de bajo nivel, y que incluso el lenguaje de máquina es “de alto nivel”. Entonces la conclusión sería que primero hay que crear una nueva generación de hardware que exponga mucha más complejidad en la arquitectura del conjunto de instrucciones, y recién después diseñar un lenguaje de bajo nivel que la aproveche
    Ambos argumentos son valiosos, pero ponerlos juntos en un solo texto y titularlo “C no es un lenguaje de bajo nivel” resulta algo inestable. El primer argumento encaja con ese título; el segundo habría quedado mejor como un artículo posterior titulado “El lenguaje de máquina tampoco es un lenguaje de bajo nivel”

    • Se sabe que IA-64 de Intel exponía al lenguaje de máquina niveles más bajos del procesador
      Pero tengo entendido que los tiempos de compilación eran largos y que los compiladores nunca llegaron al nivel de optimización esperado. El hecho de que no fuera compatible con x86 tampoco ayudó a su adopción
  • Me viene a la mente VLIW. Según el artículo de Wikipedia sobre Itanium:
    “Una sola palabra de instrucción VLIW puede contener varias instrucciones independientes que pueden ejecutarse en paralelo sin evaluación de independencia. El compilador debe intentar encontrar combinaciones válidas de instrucciones que puedan ejecutarse al mismo tiempo y, en la práctica, realiza la planificación de instrucciones que los procesadores superescalares convencionales hacen en tiempo de ejecución mediante hardware.”
    Si la CPU expone el paralelismo de un solo flujo en la interfaz, se puede resolver en tiempo de compilación o incluso decidirlo directamente con ensamblador en línea.
    Me pregunto si la razón por la que esto no prosperó se debe a la dinámica comercial de la industria, o si hay motivos técnicos por los que esta estrategia en realidad no es buena.

    • Si no recuerdo mal, no prosperó principalmente por dos razones.
      Primero, los compiladores no eran buenos haciendo ese tipo de planificación de instrucciones, y cuando mejoraron, Itanium ya se había hundido. Segundo, los conjuntos de instrucciones existentes, es decir x86, llegaron a manejar esto bastante bien en tiempo de ejecución mediante hardware, y de hecho obtenían resultados un poco mejores que la planificación estática, porque en tiempo de ejecución se dispone de datos de perfilado.
      Creo que Linus dejó en [0] una buena diatriba algo relacionada con este tema: “Mientras la gente de RISC intentaba optimizar compiladores para producir bucles que usaran eficientemente los 32 registros, los implementadores de x86, en cambio, hicieron que el chip fuera rápido bajo distintas cargas y usaron una enorme cantidad de hardware de renombrado de registros. También están mirando el renombrado de memoria.”
      [0] https://yarchive.net/comp/linux/x86.html
    • La complejidad puede moverse, en cierta medida, de un lado a otro entre el compilador, el runtime y la implementación del procesador.
      VLIW funciona realmente bien en algunos nichos. Es más difícil de programar, ya sea a mano o con compilador, que una sola instrucción ejecutada en orden, pero simplifica la planificación en hardware. Funciona mejor cuando las latencias de las instrucciones agrupadas son similares.
      Hoy, el rompecabezas central de diseño está en que los accesos a memoria consumen muchos más ciclos que la aritmética. No tiene mucho sentido agrupar una operación aritmética de unos pocos ciclos con una carga de memoria de varios cientos de ciclos. Por eso VLIW funciona bien cuando se sabe que el acceso a memoria será rápido, más o menos cuando se sabe que va a caber en la caché L1 o algo equivalente. Creo que esa es una de las razones por las que encaja bien en sistemas estilo DSP.
      Los pipelines expuestos también son una característica interesante de algunos de estos sistemas. Si una instrucción dentro de un paquete VLIW escribe en un registro, las instrucciones posteriores que leen ese mismo registro verán el valor anterior durante los siguientes N ciclos, y solo después la escritura se vuelve visible. Programarlo a mano es realmente confuso, pero el compilador puede encargarse de esa planificación.
    • La planificación estática es pésima para cargas que no son DSP ni HPC, como las aplicaciones típicas de servidor o escritorio, donde el flujo de control y el flujo de datos dependen mucho de la entrada.
      Hasta hace poco, DSP y HPC eran una porción muy pequeña del mercado, así que las arquitecturas con planificación dinámica recibieron más inversión y terminaron dominando incluso esos mercados.
      En las GPU, por supuesto, la situación fue distinta, y de hecho las GPU dependieron más de la planificación estática. Pero a medida que las GPU se expanden hacia cargas más variadas, también están incorporando cada vez más elementos dinámicos.
    • En otros comentarios ya se explicó por qué VLIW tenía fallas técnicas.
      https://news.ycombinator.com/context?id=37900987
    • Estoy leyendo que TeraScale (AMD) funciona de esta manera.
      Itanium fue el principal intento de lanzar esto como CPU. Hoy dominan AMD64 y ARM, pero quizás lo veamos de nuevo en el futuro.