Notxor tiene un blog

Defenestrando la vida

Un revuelto de cosas mías

Notxor
2025-04-27

Llevo un tiempo sin escribir y la verdad que no es por falta de ganas, sino porque me enfrasco en mis cosas y no termino de encontrar temas que piense que son interesantes para el resto del mundo mundial. ¿En qué ando enfrascado? y ¿qué me he decidido a contar hoy aquí?. Pues veamos:

Lua y C

Para aprender el lenguaje y sus interacciones, estando embebido en una aplicación escrita en otro lenguaje, necesitaba una aplicación que hospedara un intérprete de Lua para hacer funcionar algo. Debía ser una aplicación suficientemente compleja como para poner a prueba el asunto, pero que no me distrajera del objetivo último, que es aprender Lua y sus idiosincrasias.

Elegí un proyecto con el que estoy ya familiarizado, un raytracer. El objetivo es hacer una aplicaciónEn C. que genere una imagen que venga descrita en un script escrito en Lua.

El primer paso consistió en programar en Lua el mismo raytracer que se desarrollará en C hasta alcanzar un punto en que hubiera más de un objeto (en concreto dos esferas) y una abstracción de cámara para hacer la imagen. Además, también con el objeto de aprender, implementé un sistema de corrutinas que remedaban un proceso paralelo. En realidad, Lua no implementa ese paralelismo, por mucho que llame thread al proceso que realiza una corrutina, no es un hilo independiente. Me sirvió para aprender cómo gestionarlas y si lo necesitara más adelante, bastaría con lanzar la corrutina en un nuevo intérprete.

Llegados a este punto, me puse a desarrollar lo mismo en C. La primera impresión fue el cambio en velocidad. No quiere decir que Lua sea lento, sino que C es rapidísimo. Por poner un sencillo ejemplo:

El código en Lua:

local math = require("math")
local string = require("string")

local function main()
   -- Imagen
   local ancho_imagen = 256
   local alto_imagen = 256

   -- Render

   print("P3\n" .. ancho_imagen .. " " .. alto_imagen .. "\n255\n")

    for j = 1, ancho_imagen do
        io.stderr:write(".") -- muestra un punto por cada línea dibujada
        for i = 1, alto_imagen do
            local r = i / ancho_imagen
            local g = j / alto_imagen
            local b = 0.0

            local er = math.modf(255.99999 * r)
            local eg = math.modf(255.99999 * g)
            local eb = math.modf(255.99999 * b)

            print(string.format("%d %d %d", er, eg, eb))
        end
    end
    io.stderr:write(" Hecho.\n")
end

main()

Ejecución y desempeño:

time lua .ejemplo.lua > ejemplo_lua.ppm
________________________________________________________
Executed in  331.23 millis    fish           external
   usr time   69.97 millis    0.00 millis   69.97 millis
   sys time  260.53 millis    1.64 millis  258.89 millis

Si comparamos este código con el equivalente de C:

#include <stdio.h>

int main() {
  int ancho_imagen = 256;
  int alto_imagen = 256;

  printf("P3 %d %d 255\n", ancho_imagen, alto_imagen);
  for (int j = 0; j <= alto_imagen - 1; j++) {
    fprintf(stderr, ".");
    for (int i = 0; i <= ancho_imagen - 1; i++) {
      double r = (double) i / ((double) ancho_imagen - 1);
      double g = (double) j / ((double) alto_imagen - 1);
      double b = 0.0;

      int ir = (int) 255.9999 * r;
      int ig = (int) 255.9999 * g;
      int ib = (int) 255.9999 * b;
      printf("%d %d %d\n", ir, ig, ib);
    }
  }
  fprintf(stderr, "-> Hecho.\n");
}

Compilado, ejecución y desempeño:

gcc -o ejemplo_c ejemplo.c
time ./ejemplo_c > ejemplo_c.ppm
________________________________________________________
Executed in   15.85 millis    fish           external
   usr time   10.19 millis    0.00 millis   10.19 millis
   sys time    4.53 millis    1.48 millis    3.06 millis

Comparaciones odiosas

Sí, soy consciente de que no son comparables, uno es un lenguaje interpretado y otro un lenguaje compilado... para ser más odioso aún, implementé el mismo ejemplo en Rust con el siguiente resultado:

________________________________________________________
Executed in  277.37 millis    fish           external
   usr time   17.41 millis    1.41 millis   15.99 millis
   sys time  260.06 millis    0.16 millis  259.90 millis

O dicho de otro modo:

Lenguaje usr sys total
Lua 69.97 260.53 331.23
C 10.19 4.53 15.85
Rust 17.41 260.06 260.06

Es decir, Rust se acerca más a C en el tiempo dedicado en usr pero gasta el mismo tiempo que Lua en sys. Será también mucho más rápido que Lua en un proceso de cálculo más largo y exigente, pero en este ejemplo corto, apenas consigue ventaja.

No le daré más vueltas de momento a estos temas, que dejaré para más adelante, porque puestos a mirar el asunto, añadí algún lenguaje compilado más, como nim y zig.

El proyecto de aprendizaje aún está en desarrollo y me estoy divirtiendo un montón con él. Si consigo una buena integración entre ambos lenguajes no me extrañaría que lo siguiera desarrollando un poco, para conseguir mayor complejidad. Me refiero a verdadera multitarea, más tipos de objetos que simples esferas, otros formatos de salida (PNG, JPEG...) y, en fin, un raytracer más completo.

Usando Emacs para programar

Como he dicho antes estoy en fase de pruebas más profundas de eglot aprovechando este proyecto. Lo tengo activado tanto en Lua como C. Si tienes curiosidad por conocer qué configuración estoy usando, el código está en su repositorio: https://codeberg.org/Notxor/init-emacs.

En el caso del lenguaje C estoy probando las dos herramientas LSP que tengo disponibles: clangd y ccls. Ambos cuentan con paquetes propios para OpenSuse Tumbleweed y se instalan sin problemas. El primero viene integrado en los paquetes de desarrollo de clang y LLVM, y el segundo viene en un paquete aparte.

El desempeño de ambos es correcto, con sus puntos fuertes y débiles. Por ejemplo, la información que ofrece clangd me está pareciendo más completa, pero a cambio de tocar un poco más las gónadas con cosas que aparecen (y desaparecen) según le parece a él durante la edición. De vez en cuando, con el ánimo de probar ambos, cambio entre ellos y como resumen puedo decir que me gusta más ccls por su sencillez y por no molestarme con demasiados desplegables o introduciendo elementos visuales entre el código. Una costumbre que sí tiene clangd, que inserta visualmente en las llamadas a una función el nombre de los parámetros que se encuentran en su definición... es información relevante, pero me resulta incómodo leer el código así.

Depurando el código con gdb

Durante la programación me he encontrado con algunos errores para los que he necesitado utilizar un depurador. Recuerdo que años atrás, antes de descubrir las capacidades de Emacs, utilizaba herramientas como ddd para depurar. En la actualidad me encuentro más cómodo con el depurador integrado en Emacs.

Captura_gdb.png

Esta ventana aparece cuando lanzamos projectile-run-gdb si tenemos activado gdb-many-windows. Vemos seis ventanas:

gud VV locales
código Entrada-Salida
pila llamadas Breakpoints

La ventana superior izquierda nos permite interactuar con gdb. Si no lo has utilizado frecuentemente los comandos más habituales son:

gdb gud Acción
b C-x C-a C-b Establece un breakpoint.
d N C-x C-a C-d Elimina el breakpoint N.
r C-x C-a C-v Ejecuta el programa hasta encontrar un breakpoint o un error.
c C-x C-a C-r Continúa la ejecución hasta encontrar el siguiente breakpoint o un error.
f   Ejecuta el programa hasta finalizar la función en curso.
s C-x C-a C-s Ejecuta la siguiente línea de código.
s N   Ejecuta las siguientes N líneas.
n C-x C-a C-n Como s pero sin entrar en las funciones llamadas.
u N C-x C-a C-u Ejecuta el código hasta que se encuentra a N líneas de la actual.
p var C-x C-a C-p Muestra el valor de la variable var.
u C-x C-a < Sube un nivel en la pila de ejecución.
d C-x C-a > Desciende un nivel en la pila de ejecución.
q   Sale del depurador.

Se puede utilizar tanto el comando de gdb como el de gud.

A pesar de estar en las primeras fases del proyecto, me he visto ya en la necesidad de utilizarlo y es una de esas joyas de las que no se suele hablar. Según vas ejecutando puedes ir viendo cómo cambia la pila de llamadas, las variables locales y demás información.

Por ejemplo, al principio no me preocupaba la gestión de memoria. Apenas abría unas cuantas variables y no me producía ningún problema que se quedaran activos algunos punteros hasta que terminara la ejecución del programa y el S.O. liberara los recursos reservados por el programa. Para ser sincero, tampoco me crea muchos problemas de memoria en su estado actual, sin embargo, el uso de la memoria ha crecido exponencialmente. Todo el raytracer está basado en vectores 3D, incluso los colores son vectores de tres posiciones. Y cada posición es un double, por lo que un vector, un punto del espacio o un color, consumen 24 bytes. Por otro lado, un rayo está formado por un punto y un vector de dirección, por lo que consume 48 bytes. Por cada pixel de una imagen hay que lanzar al menos un rayo. Con el antialising, esto se multiplica, que pueden ser 100 rayos por pixel... con unos sencillos cálculos te darás cuenta que además de los 24bytes del color del pixel, necesitas 4.8Kb para calcularlo. Aún siendo una imagen pequeña, gastar 4.824 bytes en cada pixel, es un gasto considerable. Por ejemplo, una prueba de 400x225 pixeles son 434.160.000 bytes que son 414Mb. La solución es liberar cada rayo, cada punto, cada vector cuando deja de ser necesario.

Pruebas con distintos lenguajes

Como ya apunté antes, cuando me puse a trabajar con C, me encontré haciendo pruebas con distintos lenguajes Lua, C, Rust... y añadí también otro par de los lenguajes compilados: nim y Zig. ¿Para qué? Pues para probar, simplemente. Soy consciente de que estas pruebas no están optimizadas, es un bucle pequeño, por lo que no esperaba encontrar muchas diferencias entre ellos. Para comenzar, colocaré aquí el código.

Rust:

fn main() {
    // imagen

    let ancho_imagen: i32 = 256;
    let alto_imagen: i32 = 256;

    println!("P3 {} {} 255", ancho_imagen, alto_imagen);

    for j in 0..alto_imagen {
        eprint!(".");
        for i in 0..ancho_imagen {
            let r: f64 = (i as f64) / ((ancho_imagen as f64) - 1.0);
            let g: f64 = (j as f64) / ((alto_imagen as f64) - 1.0);
            let b: f64 = 0.0;

            let ir: i32 = (255.9999 * r) as i32;
            let ig: i32 = (255.9999 * g) as i32;
            let ib: i32 = b as i32;
            println!("{} {} {}", ir, ig, ib);
        }
    }
    eprintln!("->Hecho.");
}

Compilación con el comando:

rustc -o ejemplo_rust -O ejemplo.rs

Nim:

proc main():void =
  var ancho_imagen: int = 256
  var alto_imagen: int = 256

  echo "P3 ", ancho_imagen, " ", alto_imagen, " 255"

  for j in 0..alto_imagen-1:
    for i in 0..ancho_imagen-1:
      var r: float = float(i) / (float(ancho_imagen) - 1)
      var g: float = float(j) / (float(alto_imagen) - 1)
      var b: float = 0.0

      var ir: int = int(255.9999 * r)
      var ig: int = int(255.9999 * g)
      var ib: int = int(255.9999 * b)
      echo ir, " ", ig, " ", ib
main()

Compilación:

nim c --opt:speed ejemplo.nim

Zig:

const std = @import("std");

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();
    const ancho_imagen: i32 = 256;
    const alto_imagen: i32 = 256;

    try stdout.print("P3 {} {} 255\n", .{ ancho_imagen, alto_imagen });
    for (0..alto_imagen) |j| {
        for (0..ancho_imagen) |i| {
            const r: f64 = @as(f64, @floatFromInt(i)) / (ancho_imagen - 1);
            const g: f64 = @as(f64, @floatFromInt(j)) / (alto_imagen - 1);
            const b: f64 = 0.0;

            const ir: i32 = @as(i32, @intFromFloat(255.9999 * r));
            const ig: i32 = @as(i32, @intFromFloat(255.9999 * g));
            const ib: i32 = @as(i32, @intFromFloat(255.9999 * b));

            try stdout.print("{} {} {}\n", .{ ir, ig, ib });
        }
    }
}

Compilación:

zig build-exe ejemplo.zig

Los resultado son los siguientes:

Lenguaje usr sys total
C 10.19 4.53 15.85
Rust 17.41 260.06 260.06
nim 39.48 523.69 563.03
Zig 160.00 2980.00 3140.00

No son pruebas definitivas. Es decir, llama la atención que un lenguaje compilado, como Zig se vaya a los 3,14 segundos en la ejecución obteniendo peores resultados incluso que Lua.

Seguramente, cualquier lenguaje de esos puede compilarse más optimizado y por desconocimiento no lo hice.

Conclusiones

La principal es que ando con mis cosas. No me olvido del blog, pero cada vez me cuesta más encontrar temas interesantes. Para mí era más fácil escribir cuando escribía sólo para mí. Desde que me di cuenta que había gente que lo leía pienso más en qué puede interesar a otros que en lo que me interesa a mí. Pero normalmente ando haciendo algún proyecto personal, que me parece interesante pero que no termino decidirme a contarlo por aquí.

Otro tema tangencial son los lenguajes. Para hacer los ejemplos he tenido que hacer uno en Zig, un lenguaje por completo desconocido. Es el primer código que escribo en él. Me ha llamado la atención lo complejo que puede ser su compilador. Parece un lenguaje bastante expresivo y no descarto aprender más sobre él en el futuro.

De momento estoy disfrutando bastante con el raytracer en C y Lua, es posible que lo alargue en sus capacidades.

Categoría: Lua C eglot emacs

Comentarios

Debido a algunos ataques mailintencionados a través de la herramienta de comentarios, he decidido no proporcionar dicha opción en el Blog. Si alguien quiere comentar algo, me puede encontrar en esta cuenta de Mastodon, también en esta otra cuenta de Mastodon y en Diaspora con el nick de Notxor.

Si usas habitualmente XMPP (si no, te recomiendo que lo hagas), puedes encontrar también un pequeño grupo en el siguiente enlace: notxor-tiene-un-blog@salas.suchat.org

Disculpen las molestias.

Escrito enteramente por mi Estulticia Natural sin intervención de ninguna IA.