¡Hola! Soy Oleksandr Kaleniuk y soy adicto a C++. He estado escribiendo en C++ durante 17 años y durante todos esos 17 años he estado tratando de deshacerme de esta devastadora adicción.
Todo comenzó en 2005 con un motor de simulación espacial 3D. El motor tenía todo lo que C++ tenía en 2005. Punteros de tres estrellas, ocho capas de dependencia y macros de estilo C en todas partes. También había piezas de montaje. Iteradores al estilo Stepanov y metacódigo al estilo Alexandrescu. El código lo tenía todo. Excepto, por supuesto, la respuesta a la pregunta más importante: ¿por qué?
En un tiempo, incluso esta pregunta fue respondida. Simplemente no como "para qué", sino más bien como "cómo es posible". Al final resultó que, el motor ha sido escrito durante unos 8 años por 5 equipos diferentes. Y cada equipo aportó su moda favorita al proyecto, envolviendo el código antiguo en envoltorios recién diseñados, agregando solo alrededor de 10 a 20 microcarmacks de funcionalidad mientras lo hacía.
Al principio, honestamente estaba tratando de asimilar cada pequeña cosa. Esa no fue una experiencia gratificante, para nada, y en algún momento me rendí. Todavía estaba cerrando tareas y arreglando errores. No puedo decir que fui muy productivo, pero lo suficiente como para no ser despedido. Pero luego mi jefe me preguntó: "¿quieres reescribir parte del código de sombreado de Assembly a GLSG?" Pensé que Dios sabe cómo se ve este GLSL, pero no podría ser peor que C++ y dije que sí. No fue peor.
Y esto se convirtió en un patrón. Todavía escribía principalmente en C++, pero cada vez que alguien me preguntaba "¿quieres hacer eso que no es C++?" ¡Yo Estaba Seguro!" y yo hice esa cosa, sea lo que sea. Escribí en C89, MASM32, C#, PHP, Delphi, ActionScript, JavaScript, Erlang, Python, Haskell, D, Rust e incluso en ese escandalosamente malo lenguaje de secuencias de comandos InstallShield. Escribí en VisualBasic, en bash y en algunos lenguajes propietarios, de los que ni siquiera puedo hablar legalmente. Incluso hice uno yo mismo por accidente. Creé un intérprete de estilo ceceo simple para ayudar a los diseñadores de juegos a automatizar la carga de recursos y me fui de vacaciones. Cuando regresé, estaban escribiendo todas las escenas del juego en este intérprete, así que tuvimos que apoyarlo hasta, al menos, el final del proyecto.
Entonces, durante los últimos 17 años, honestamente intenté dejar C ++, pero cada vez que probaba algo nuevo y brillante, regresaba. Sin embargo, creo que escribir en C++ es un mal hábito. No es seguro, no es tan efectivo como se cree y desperdicia una cantidad terrible de la capacidad mental de un programador en cosas que no tienen nada que ver con la creación de software. ¿Sabe que en MSVC uint16_t(50000) + uin16_t(50000) == -1794967296
? ¿Sabes por qué? Sí, eso es lo que yo pensaba.
Creo que es la responsabilidad moral de los programadores de C++ desde hace mucho tiempo desalentar a la generación joven de hacer de C++ su profesión más o menos como es la responsabilidad moral de los alcohólicos que no pueden dejar de advertir a los jóvenes sobre el peligro.
Pero ¿por qué no puedo dejar de fumar? ¿Qué pasa? El asunto es que ninguno de los lenguajes, especialmente los llamados "asesinos de C ++", brindan una ventaja real sobre C ++ en el mundo moderno. Todos esos nuevos lenguajes se centran principalmente en mantener a un programador atado por su propio bien. Esto está bien, excepto que escribir buen código con malos programadores es un problema del siglo XX, cuando los transistores se duplicaban cada 18 meses y la cantidad de programadores se duplicaba cada 5 años.
Estamos viviendo en 2023. Tenemos más programadores experimentados en el mundo que nunca antes en la historia. Y necesitamos un software eficiente ahora más que nunca.
Las cosas eran más simples en el siglo XX. Tienes una idea, la envuelves en una interfaz de usuario y la vendes como un producto de escritorio. ¿Es lento? ¡A quién le importa! De todos modos, en dieciocho meses, los escritorios serán el doble de rápidos. Lo que importa es entrar en el mercado, empezar a vender funciones y, preferiblemente, sin errores. En ese clima, claro, si un compilador evita que los programadores creen errores, ¡bien! Porque los errores no generan dinero, y usted tiene que pagar a sus programadores si agregan funciones o errores de todos modos.
Ahora las cosas son diferentes. Tienes una idea, la envuelves en un contenedor Docker y la ejecutas en una nube. Ahora obtiene sus ingresos de las personas que ejecutan su software si hace que sus problemas desaparezcan. Incluso si hace una cosa pero la hace bien, le pagarán. No tiene que llenar su producto con funciones inventadas solo para vender una nueva versión. Por otro lado, el que paga la ineficacia de tu código ahora eres tú mismo. Cada rutina subóptima se muestra en su factura de AWS.
Entonces, en el nuevo clima, ahora necesita menos funciones, pero un mejor rendimiento para lo que sea que tenga.
Y de repente resulta que todos los "asesinos de C++", incluso aquellos que amo y respeto de todo corazón como Rust, Julia y D, no abordan el problema del siglo XXI. Todavía están atrapados en el XX. Lo ayudan a escribir más funciones con menos errores, pero no son de mucha ayuda cuando necesita exprimir hasta el último fracaso del hardware que alquila.
Simplemente no le dan una ventaja competitiva sobre C++. O, para el caso, incluso uno sobre el otro. La mayoría de ellos, por ejemplo, Rust, Julia y Cland incluso comparten el mismo backend. No puedes ganar una carrera de autos si todos comparten el mismo auto.
Entonces, ¿qué tecnologías le brindan una ventaja competitiva sobre C++ o, hablando en general, todos los compiladores tradicionales adelantados? Buena pregunta. Me alegro de que hayas preguntado.
Pero antes de ir con la propia Espiral, veamos qué tan bien funciona tu intuición. ¿Qué crees que es más rápido: una función de seno estándar de C++ o un modelo polinomial de 4 piezas de un seno?
auto y = std::sin(x); // vs. y = -0.000182690409228785*x*x*x*x*x*x*x +0.00830460224186793*x*x*x*x*x -0.166651012143690*x*x*x +x;
Próxima pregunta. ¿Qué funciona más rápido, usar operaciones lógicas con cortocircuito o engañar a un compilador para evitarlo y calcular la expresión lógica a granel?
if (xs[i] == 1 && xs[i+1] == 1 && xs[i+2] == 1 && xs[i+3] == 1) // xs are bools stored as ints // vs. inline int sq(int x) { return x*x; } if(sq(xs[i] - 1) + sq(xs[i+1] - 1) + sq(xs[i+2] - 1) + sq(xs[i+3] - 1) == 0)
Y uno más ¿Qué ordena los trillizos más rápido: una ordenación por intercambio o una ordenación por índice?
if(s[0] > s[1]) swap(s[0], s[1]); if(s[1] > s[2]) swap(s[1], s[2]); if(s[0] > s[1]) swap(s[0], s[1]); // vs. const auto a = s[0]; const auto b = s[1]; const auto c = s[2]; s[int(a > b) + int(a > c)] = a; s[int(b >= a) + int(b > c)] = b; s[int(c >= a) + int(c >= b)] = c;
Si respondiste todas las preguntas con decisión y sin siquiera pensar o buscar en Google, entonces tu intuición te falló. No viste la trampa. Ninguna de estas preguntas tiene una respuesta definitiva sin contexto.
¿A qué CPU o GPU se dirige el código? ¿Qué compilador se supone que construye el código? ¿Qué optimizaciones del compilador están activadas y cuáles están desactivadas? Solo puede comenzar a predecir cuando sepa todo eso, o mejor aún, midiendo el tiempo de ejecución para cada solución en particular.
Un modelo polinomial es 3 veces más rápido que el seno estándar si se construye con clang 11 con -O2 -march=native y se ejecuta en Intel Core i7-9700F. Pero si se construye con nvcc con --use-fast-math y en GPU, a saber, GeForce GTX 1050 Ti Mobile , el seno estándar es 10 veces más rápido que el modelo.
Intercambiar lógica en cortocircuito por aritmética vectorizada también tiene sentido en i7. Hace que el fragmento funcione el doble de rápido. Pero en ARMv7 con el mismo sonido metálico y -O2, la lógica estándar es un 25 % más rápida que la microoptimización.
Y con ordenación por índice frente a ordenación por intercambio, la ordenación por índice es 3 veces más rápida en Intel y la ordenación por intercambio es 3 veces más rápida en GeForce.
Por lo tanto, las queridas micro optimizaciones que todos amamos pueden acelerar nuestro código en un factor 3 y ralentizarlo en un 90 %. Todo depende del contexto. Qué maravilloso sería si un compilador pudiera elegir la mejor alternativa para nosotros, por ejemplo, la ordenación por índice se convertiría milagrosamente en una ordenación por intercambio cuando cambiamos el objetivo de compilación. Pero no pudo.
Incluso si permitimos que el compilador vuelva a implementar el seno como un modelo polinomial, para intercambiar precisión por velocidad, aún no conoce nuestra precisión objetivo. En C++, no podemos decir que “esta función puede tener ese error”. Todo lo que tenemos son indicadores del compilador como "--use-fast-math" y solo en el ámbito de una unidad de traducción.
En el segundo ejemplo, el compilador no sabe que nuestros valores están limitados a 0 o 1 y no puede proponer la optimización que nosotros podemos. Probablemente podríamos haberlo insinuado usando un tipo de bool adecuado, pero ese habría sido un problema completamente diferente.
Y en el tercer ejemplo, las piezas de código son muy diferentes para ser reconocidas como sinónimos. Detallamos demasiado el código. Si fuera solo std::sort, esto ya le habría dado al compilador más libertad para elegir el algoritmo. Pero no habría elegido index-sort ni swap sort, ya que ambos son ineficientes en arreglos grandes y std::sort funciona con un contenedor iterable genérico.
Y así llegamos a Espiral . Es un proyecto conjunto de la Universidad Carnegie Mellon y Eidgenössische Technische Hochschule Zürich. TL&DR: los expertos en procesamiento de señales se aburrieron de reescribir a mano sus algoritmos favoritos para cada nueva pieza de hardware y escribieron un programa que hace este trabajo por ellos. El programa toma una descripción de alto nivel de un algoritmo y una descripción detallada de la arquitectura del hardware, y optimiza el código hasta que realiza la implementación del algoritmo más eficiente para el hardware especificado.
Una distinción importante entre Fortran y similares, Spiral realmente resuelve un problema de optimización en el sentido matemático. Define el tiempo de ejecución como una función objetivo y busca su óptimo global en el espacio de factores de las variantes de implementación limitadas por la arquitectura del hardware. Esto es algo que los compiladores nunca hacen.
Un compilador no busca el verdadero óptimo. Optimiza el código guiado por las heurísticas que le enseñaron los programadores. Esencialmente, un compilador no funciona como una máquina que busca la solución óptima, sino como un programador ensamblador. Un buen compilador funciona como un buen programador ensamblador, pero eso es todo.
Espiral es un proyecto de investigación. Es limitado en alcance y presupuesto. Pero los resultados que muestra ya son impresionantes. En la transformada rápida de Fourier, su solución supera de manera decisiva las implementaciones de MKL y FFTW. Su código es ~2 veces más rápido. Incluso en Intel.
Solo para resaltar la escala de logros, MKL es la Biblioteca de núcleos matemáticos de Intel, por lo tanto, de los muchachos que saben cómo usar su hardware al máximo. Y WWTF, también conocido como "Fastest Fourier Transform in the West", es una biblioteca altamente especializada de los muchachos que mejor conocen el algoritmo. Ambos son campeones en lo que hacen y el mero hecho de que Spiral los venza a ambos es asombroso.
Cuando la tecnología de optimización que utiliza Spiral se finalice y comercialice, no solo C++, sino también Rust, Julia e incluso Fortran se enfrentarán a una competencia a la que nunca antes se habían enfrentado. ¿Por qué alguien escribiría en C++ si escribir en un lenguaje de descripción de algoritmos de alto nivel hace que su código sea 2 veces más rápido?
El mejor lenguaje de programación es el que ya conoces bien. Durante varias décadas seguidas, el lenguaje más conocido para la mayoría de los programadores ha sido C. También lidera el índice TIOBE con otros C-likes que ocupan el top 10. Sin embargo, hace solo dos años sucedió algo inaudito. La C le dio su primer lugar a otra cosa.
El "algo más" parecía ser Python. Un lenguaje que nadie tomó en serio en los años 90 porque era otro lenguaje de secuencias de comandos del que ya teníamos mucho.
Alguien dirá: “Bah, Python es lento”, y quedará como un tonto ya que esto es una tontería terminológica. Al igual que un acordeón o una sartén, un idioma simplemente no puede ser rápido o lento. Al igual que la velocidad de un acordeón depende de quién lo toque, la "velocidad" de un lenguaje depende de qué tan rápido sea su compilador.
“Pero Python no es un lenguaje compilado”, puede continuar alguien, y fallar una vez más. Hay muchos compiladores de Python y el más prometedor de ellos es, a su vez, un script de Python. Dejame explicar.
Una vez tuve un proyecto. Una simulación de impresión 3D que se escribió originalmente en Python y luego se reescribió en C++ "para el rendimiento", y luego se transfirió a la GPU, todo eso antes de que yo entrara. Luego pasé meses transfiriendo la compilación a Linux, optimizando el código de la GPU para Tesla M60 ya que era el más barato en AWS en ese momento, y validando todos los cambios en el código C ++/CU para que coincida con el código original en Python. Así que hice todo excepto las cosas en las que normalmente me especializo, es decir, diseñar algoritmos geométricos.
Y cuando finalmente tuve todo funcionando, un estudiante de Bremen a tiempo parcial me llamó y me preguntó: "entonces eres bueno en cosas heterogéneas, ¿puedes ayudarme a ejecutar un algoritmo en GPU?" ¡Por supuesto! Le hablé sobre CUDA, CMake, compilación, prueba y optimización de Linux; pasó tal vez una hora hablando. Escuchó todo eso muy cortésmente, pero al final dijo: “Todo esto es muy interesante, pero tengo una pregunta muy específica. Así que tengo una función, escribí @cuda.jit antes de su definición, y Python dice algo sobre matrices y no compila el kernel. ¿Sabes cuál podría ser el problema aquí?
no lo sabía Lo descubrió él mismo en un día. Aparentemente, Numba no funciona con listas nativas de Python, solo acepta datos en matrices NumPy. Así que lo descubrió y ejecutó su algoritmo en GPU. En Python. No tenía ninguno de los problemas en los que pasé meses. ¿Lo quieres en Linux? No hay problema, simplemente ejecútelo en Linux. ¿Quieres que sea consistente con el código de Python? No hay problema, es código de Python. ¿Desea optimizar para la plataforma de destino? No es un problema de nuevo. Numba optimizará el código para la plataforma en la que ejecuta el código, ya que no se compila antes de tiempo, se compila a pedido cuando ya está implementado.
¿No es maravilloso? Bueno no. No para mí de todos modos. Pasé meses con C++ resolviendo problemas que nunca ocurren en Numba, y un trabajador a tiempo parcial de Bremen hizo lo mismo en unos pocos días. Podrían haber sido unas pocas horas si no fuera su primera experiencia con Numba. Entonces, ¿qué es este Numba? ¿Qué tipo de brujería es?
Sin brujería. Los decoradores de Python convierten cada pieza de código en su árbol de sintaxis abstracto para usted, para que luego pueda hacer lo que quiera con él. Numba es una biblioteca de Python que quiere compilar árboles de sintaxis abstractos con cualquier backend que tenga y para cualquier plataforma que admita. Si desea compilar su código Python para que se ejecute en los núcleos de la CPU de forma masivamente paralela, simplemente dígale a Numba que lo compile así. Si desea ejecutar algo en la GPU, nuevamente, solo debe preguntar .
@cuda.jit def matmul(A, B, C): """Perform square matrix multiplication of C = A * B.""" i, j = cuda.grid(2) if i < C.shape[0] and j < C.shape[1]: tmp = 0. for k in range(A.shape[1]): tmp += A[i, k] * B[k, j] C[i, j] = tmp
Numba es uno de los compiladores de Python que hace que C++ quede obsoleto. En teoría, sin embargo, no es mejor que C++ ya que usa los mismos backends. Utiliza CUDA para programación de GPU y LLVM para CPU. En la práctica, dado que no requiere una reconstrucción previa para cada nueva arquitectura, las soluciones de Numba se adaptan mejor a cada nuevo hardware y sus optimizaciones disponibles.
Por supuesto, sería mejor tener una clara ventaja de rendimiento como es con Spiral. Pero Spiral es más un proyecto de investigación, podría matar a C ++ pero solo eventualmente, y solo si tiene suerte. Numba con Python estrangula a C++ ahora mismo, en tiempo real. Porque si puedes escribir en Python y tener el rendimiento de C++, ¿por qué querrías escribir en C++?
Juguemos otro juego. Te daré tres fragmentos de código y adivinarás cuál de ellos, o quizás más, está escrito en ensamblador. Aquí están:
invoke RegisterClassEx, addr wc ; register our window class invoke CreateWindowEx,NULL, ADDR ClassName, ADDR AppName,\ WS_OVERLAPPEDWINDOW,\ CW_USEDEFAULT, CW_USEDEFAULT,\ CW_USEDEFAULT, CW_USEDEFAULT,\ NULL, NULL, hInst, NULL mov hwnd,eax invoke ShowWindow, hwnd,CmdShow ; display our window on desktop invoke UpdateWindow, hwnd ; refresh the client area .while TRUE ; Enter message loop invoke GetMessage, ADDR msg,NULL,0,0 .break .if (!eax) invoke TranslateMessage, ADDR msg invoke DispatchMessage, ADDR msg .endw
(module (func $add (param $lhs i32) (param $rhs i32) (result i32) get_local $lhs get_local $rhs i32.add) (export "add" (func $add)))
v0 = my_vector // we want the horizontal sum of this int64 r0 = get_len ( v0 ) int64 r0 = round_u2 ( r0 ) float v0 = set_len ( r0 , v0 ) while ( uint64 r0 > 4) { uint64 r0 >>= 1 float v1 = shift_reduce ( r0 , v0 ) float v0 = v1 + v0 }
Entonces, ¿cuál, o más de uno, está en asamblea? Si crees que los tres, ¡enhorabuena! ¡tu intuición ya ha mejorado mucho!
El primero está en MASM32. Es un ensamblador de macros con "si" y "mientras" la gente escribe aplicaciones nativas de Windows. Así es, no "solía escribir" sino "escribir" hasta el día de hoy. Microsoft protege celosamente la compatibilidad con versiones anteriores de Windows con la API de Win32, por lo que todos los programas MASM32 que se han escrito también funcionan bien en las PC modernas.
Lo que es irónico, C fue inventado para facilitar la traducción de UNIX de PDP-7 a PDP-11. Fue diseñado como un ensamblador portátil capaz de sobrevivir a la explosión cámbrica de las arquitecturas de hardware de los años 70. Pero en el siglo XXI, la arquitectura del hardware evoluciona tan lentamente que los programas que escribí en MASM32 hace 20 años se ensamblan y funcionan perfectamente hoy, pero no confío en que una aplicación C++ que construí el año pasado con CMake 3.21 se construya hoy con CMake. 3.25.
La segunda pieza de código es Web Assembly. Ni siquiera es un ensamblador de macros, no tiene "si" ni "mientras", es más un código de máquina legible por humanos para su navegador. O algún otro navegador. Conceptualmente, cualquier navegador.
El código de Web Assembly no depende en absoluto de la arquitectura de su hardware. La máquina a la que sirve es abstracta, virtual, universal, llámala como quieras. Si puede leer este texto, ya tiene uno en su máquina física.
Pero la pieza de código más interesante es la tercera. Es ForwardCom, un ensamblador que propone Agner Fog, un renombrado autor de C++ y manuales de optimización de ensamblaje. Al igual que con Web Assembly, la propuesta cubre no tanto un ensamblador como el conjunto universal de instrucciones diseñado para permitir no solo la compatibilidad hacia atrás sino también hacia adelante. De ahí el nombre. El nombre completo de ForwardCom es " una arquitectura abierta de conjunto de instrucciones compatible con versiones posteriores ". En otras palabras, no es tanto una propuesta de asamblea, sino una propuesta de tratado de paz.
Sabemos que todas las familias arquitectónicas más comunes: x64, ARM y RISC-V tienen diferentes conjuntos de instrucciones. Pero nadie conoce una buena razón para mantenerlo así. Todos los procesadores modernos, aparte de quizás los más simples, no ejecutan el código con el que lo alimentas, sino el microcódigo en el que traducen tu entrada. Por lo tanto, no solo M1 tiene una capa de compatibilidad con versiones anteriores para Intel, cada procesador tiene esencialmente una capa de compatibilidad con versiones anteriores para todas sus versiones anteriores.
Entonces, ¿qué impide que los diseñadores de arquitectura acuerden una capa similar pero con compatibilidad futura? Aparte de las ambiciones conflictivas de las empresas en competencia directa, nada. Pero si los fabricantes de procesadores en algún momento se conforman con tener un conjunto de instrucciones común en lugar de implementar una nueva capa de compatibilidad para todos los demás competidores, ForwardCom llevará la programación ensambladora de vuelta a la corriente principal. Esta capa de compatibilidad hacia adelante curaría la peor neurosis de todos los programadores de ensamblaje: "¿Qué pasa si escribo el código único en la vida para esta arquitectura en particular, y esta arquitectura en particular se volverá obsoleta en un año?"
Con una capa de compatibilidad hacia adelante, nunca quedará obsoleto. Ese es el punto.
La programación ensambladora también se ve frenada por el mito de que escribir en ensamblador es difícil y, por lo tanto, poco práctico. La proposición de Fog también aborda este problema. Si la gente piensa que escribir en ensamblador es difícil y escribir en C no lo es, bueno, hagamos que el ensamblador parezca C. No hay problema. No hay una buena razón para que un lenguaje ensamblador moderno se vea exactamente igual que su abuelo en los años 50.
Usted mismo acaba de ver tres muestras de ensamblaje. Ninguno de ellos parece una asamblea “tradicional” y ninguno debería serlo.
Por lo tanto, ForwardCom es el ensamblaje en el que puede escribir un código óptimo que nunca quedará obsoleto y que no le obliga a aprender un ensamblaje "tradicional". Para todas las consideraciones prácticas, es la C del futuro. No C++.
Vivimos en un mundo posmoderno. Ya nada muere excepto la gente. Así como el latín nunca murió, al igual que COBOL, Algol 68 y Ada, C++ está condenado a una existencia eterna a medias entre la vida y la muerte. C++ en realidad nunca morirá, solo será expulsado de la corriente principal por tecnologías más nuevas y potentes.
Bueno, no “será empujado” sino “siendo empujado”. Llegué a mi trabajo actual como programador de C++ y hoy mi jornada laboral comienza con Python. Escribo las ecuaciones, SymPy me las resuelve y luego traduce la solución a C++. Luego pego este código en la biblioteca de C++ sin siquiera molestarme en formatearlo un poco, ya que clang-tidy lo hará por mí de todos modos. Un analizador estático comprobará que no arruiné los espacios de nombres y un analizador dinámico comprobará si hay fugas de memoria. CI/CD se encargará de la compilación multiplataforma. Un generador de perfiles me ayudará a comprender cómo funciona realmente mi código y un desensamblador, por qué.
Si cambio C++ por “no C++”, el 80% de mi trabajo seguirá siendo exactamente el mismo. C++ es simplemente irrelevante para la mayor parte de lo que hago. ¿Podría significar que para mí C++ ya está muerto en un 80 %?
Imagen principal desarrollada por difusión estable.