-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Ziggy thing: run Zig code in a Livebook #11
Comments
PreliminariesInstall a Zig version manager:
Because you will use a library who depends upon a nominated version.
❗
Once installed, you can use
const zigimg_dependency = b.dependency("zigimg", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("zigimg", zigimg_dependency.module("zigimg")); and you should be good to go. |
Show me some codeThe algorithm:
Quick remarks:
All these points above are [WORK IN PROGRESS] First Zig code// //! Compute Mandelbrot Set in Zig.
const std = @import("std");
const Cx = std.math.Complex(f64);
const zigimg = @import("zigimg");
const print = std.debug.print;
const IMAX: usize = 200;
const RESOLUTION = [_]u32{ 4096, 4096 };
test "complex" {
const c1 = Cx.init(1.0, 0.0);
const c2 = Cx.init(0.0, 1.0);
const c3 = Cx.add(c1, c2);
try std.testing.expectApproxEqRel(std.math.sqrt2, Cx.magnitude(c3), 1e-4);
const c4 = Cx.mul(c1, c2);
try std.testing.expectEqual(c4, c2);
}
/// Compute the square of the norm of a complex number to avoid the square root
fn sqnorm(z: Cx) f64 {
return z.re * z.re + z.im * z.im;
}
test "sqnorm" {
const z = Cx{ .re = 2.0, .im = 2.0 };
try std.testing.expectApproxEqRel(sqnorm(z), Cx.magnitude(z) * Cx.magnitude(z), 1e-4);
}
/// The function computes the number iterations for `z(n+1) = z(n)^2 + c` to escape.
/// It escapes when (squared_norm > 4) or when it reaches IMAX. Return `j`
/// If it stays bounded, return `null`
fn getIter(c: Cx) ?usize {
if (c.re > 0.6 or c.re < -2.1) return null;
if (c.im > 1.2 or c.im < -1.2) return null;
var z = Cx{ .re = 0.0, .im = 0.0 };
for (0..IMAX) |j| {
if (sqnorm(z) > 4) return j;
z = Cx.mul(z, z).add(c);
}
return null;
}
test "iter when captured" {
const c = Cx{ .re = 0.0, .im = 0.0 };
const iter = getIter(c);
try std.testing.expect(iter == null);
}
test "iter if escapes" {
const c = Cx{ .re = 1.0, .im = 1.0 };
const iter = getIter(c);
try std.testing.expect(iter != null);
}
/// Creates an RGB arrays of u8 colour based on the number of iterations.
///
/// The colour if black when the point is captured.
///
/// The brighter the color the faster it escapes.
fn createRgb(iter: ?usize) [3]u8 {
// If it didn't escape, return black
if (iter == null) return [_]u8{ 0, 0, 0 };
// Normalize time to [0,1] now that we know it escaped
const normalized = @as(f32, @floatFromInt(iter.?)) / @as(f32, @floatFromInt(IMAX));
if (normalized < 0.5) {
const scaled = normalized * 2;
return [_]u8{ @as(u8, @intFromFloat(255.0 * (1.0 - scaled))), @as(u8, @intFromFloat(255.0 * (1.0 - scaled / 2))), @as(u8, @intFromFloat(127 + 128 * scaled)) };
} else {
const scaled = (normalized - 0.5) * 2.0;
return [_]u8{ 0, @as(u8, @intFromFloat(127 * (1 - scaled))), @as(u8, @intFromFloat(255.0 * (1.0 - scaled))) };
}
}
test "createRgb" {
const iter1 = 0;
const expected1 = [_]u8{ 255, 255, 127 };
var result = createRgb(iter1);
try std.testing.expectEqualSlices(u8, &expected1, &result);
const iter2 = IMAX / 2;
const expected2 = [_]u8{ 0, 127, 255 };
result = createRgb(iter2);
try std.testing.expectEqualSlices(u8, &expected2, &result);
const iter3 = IMAX;
const expected3 = [_]u8{ 0, 0, 0 };
result = createRgb(iter3);
try std.testing.expectEqualSlices(u8, &expected3, &result);
}
const Context = struct {
resolution: [2]u32,
topLeft: Cx,
bottomRight: Cx,
};
/// Given an image of size img,
/// a complex plane defined by the topLeft and bottomRight,
/// the pixel coordinate in the output image is translated to a complex number
///
/// Example: With an image of size img=100x200, the point/pixel at 75,175,
/// should map to 0.5 + 0.5i
fn pixelToComplex(pix: [2]u32, ctx: Context) Cx {
const w = ctx.bottomRight.re - ctx.topLeft.re;
const h = ctx.topLeft.im - ctx.bottomRight.im;
const re = @as(f64, @floatFromInt(pix[0])) / @as(f64, @floatFromInt(ctx.resolution[0])) * w;
const im = @as(f64, @floatFromInt(pix[1])) / @as(f64, @floatFromInt(ctx.resolution[1])) * h;
return Cx{ .re = (ctx.topLeft.re + re) * w, .im = (ctx.topLeft.im - im) * h };
}
test "pixelToComplex" {
const topLeft = Cx{ .re = -1, .im = 1 };
const bottomRight = Cx{ .re = 1, .im = -1 };
const ctx = Context{ .resolution = .{ 100, 200 }, .topLeft = topLeft, .bottomRight = bottomRight };
const pix = .{ 75, 150 };
const expected = Cx{ .re = 0.5, .im = -0.5 };
const result = pixelToComplex(pix, ctx);
try std.testing.expect(expected.re == result.re and expected.im == result.im);
}
fn createSlice(ctx: Context, allocator: std.mem.Allocator) ![]u8 {
var pixels = try allocator.alloc(u8, ctx.resolution[0] * ctx.resolution[1] * 3);
for (0..ctx.resolution[1]) |y| {
for (0..ctx.resolution[0]) |x| {
const c = pixelToComplex(.{ @as(u32, @intCast(x)), @as(u32, @intCast(y)) }, ctx);
const iter = getIter(c);
const colour = createRgb(iter);
const pixel_index = (y * resolution[0] + x) * 3;
// copy RGB values to consecutive memory locations
pixels[pixel_index + 0] = colour[0]; //R
pixels[pixel_index + 1] = colour[1]; //G
pixels[pixel_index + 2] = colour[2]; //B
}
}
return pixels;
}
test "createSlice" {
// TODO: a meaningful test
_ = try createSlice(.{ 100, 200 }, Cx{ .re = -1, .im = 1 }, Cx{ .re = 1, .im = -1 }, std.testing.allocator);
}
fn writeToPNG(path: []const u8, pixels: []u8, resolution: [2]u32, allocator: std.mem.Allocator) !void {
const w = resolution[0];
const h = resolution[1];
var image = try zigimg.Image.fromRawPixels(allocator, w, h, pixels, .rgb24);
defer image.deinit();
try image.writeToFilePath(path, .{ .png = .{} });
}
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
const topLeft = Cx{ .re = -1, .im = 1 };
const bottomRight = Cx{ .re = 1, .im = -1 };
const ctx = Context{.resolution: RESOLUTION, .topLeft = topLeft, .bottomRight = bottomRight};
const pixels = try createSlice(ctx, allocator);
defer allocator.free(pixels);
try writeToPNG("mandelbrot.png", pixels, RESOLUTION, allocator);
} |
Speed this upGiven the symmetry of this figure (if the orbit of the complex c is bounded, then so will its conjugate), we can obviously spare half of the computation. A bit hairy to do... Then we can leverage parallelism since the points are independent. Two options are evaluated:
It turns out that the first idea is a bad idea. The overhead of spawning OS threads is such that you have no gain at all. The second option is the more efficient.
We see that we spend more time producing a PNG file from the data than producing the data itself. |
Long-running NIFs"As mentioned in the warning text at the beginning of this manual page, it is of vital importance that a native function returns relatively fast. It is difficult to give an exact maximum amount of time that a native function is allowed to work, but usually a well-behaving native function is to return to its caller within 1 millisecond. This can be achieved using different approaches. If you have full control over the code to execute in the native function, the best approach is to divide the work into multiple chunks of work and call the native function multiple times." It seems that 1 million pixels (1_000 x 1_000 pixels) is the limit for the Zig code to return on completion under 1ms. Recall the algorithm:
Zig is mutating directly a memory area by appending the computed 3-bytes array at a given location What can be done?
|
Zigler concurrent mode 🎉This Zig file runs the algorithm above but modified to run OS threads to parallelise the computations (the total number of rows is divided by the number of available CPUs, and each "band" is run in an OS thread). It exports a function In ths inline code, add: use Zig, otp_app: :zigler,
nifs: [..., generate_misiurewicz: [:threaded]],
release_mode: :fast The parameters are the image size in pixel h x w.
Zig threads```zig // draw.zig const beam = @import("beam"); const std = @import("std"); const Cx = std.math.Complex(f64);const IMAX = 300; /// nif: generate_mandelbrot/2 Threaded
} // with OS THREADS-----------------------------------
} fn processRow(res_x: usize, res_y: usize, pixels: []u8, row_id: usize) void {
} fn processRows(res_x: usize, res_y: usize, pixels: []u8, start_row: usize, end_row: usize) void { // shared functions -- fn getIter(c: Cx) ?usize {
} fn sqnorm(z: Cx) f64 { fn createRgb(iter: ?usize) [3]u8 {
} // No OS THREAD--------------------------------------
}
With: 40_000 x 40_000 pixels and max_iter = 300, I have a processing time of:
|
ConclusionZig is super fast! |
Livebook thisProgramRun in a Livebook to:
Livebook desktop config...System.cmd("which", ["zig"])
# {..., 1} 🤔 Let's check Zig:
and I check I have the path with Zig appended for Livebook to read them.
However, Livebook desktop does not find Zig. 🤨 So I installed Livebook via Escript
Add then and run:
This seems better as via the terminal my path is known to the System.cmd("which", ["zig"])
# { ~/.zvm/bin/zig\n", 0}} After these nicesties, seems like a bug Livebook/Zigler...#498 |
Pur Elixir version 🎉 in a Livebook (Zigler not ready yet)I had a discussion with Paulo Valente from Dockyard about some points on Here is an image obtained with only using Elixir with This is a It is 3 magnitude slower than Zig though (8ms), but the ease of coding and rendering in a Livebook is amazing. Like a Jupyter Notebook. |
@ndrean Awesome! 😍 |
@nelsonic quick question: did you open and tried the Livebook? If not, could you do it quickly? I still have doubts whether Zigler works. The "it works for me ™️" syndrome..... |
@ndrean ran it on Huggingface 🤗 ✅ |
@nelsonic Huggingface ?🤔 Did not know! ❗ Livebook image does not ship with Zig, it does not work (the Zig part). |
You can install whatever you want on |
What an awesome rabbit hole! 🐇 🕳️ 😍
|
Still drilling the rabbit hole .... ♾️, and turning this into a personal blog 😄 WebAssembly, Zig and LiveView 🚀My last (?) step was to produce a WASM from Not saying that I know
![]() I will also setup a Github pages to render a standalone WASM: https://ndrean.github.io/zig-assembly-test/ Ok, took me a few hours yesterday skimming through And understood a little bit about the One point: I am not sure that the WebAssembly code is faster than using "embedded Zig", simply because I used OS threads in the embedded Zig whilst WebAssembly in the browser does not support this (perhaps using Web Workers, maybe one day...). However, you don't need a backend: it runs in the browser (once you loaded it). A few words about WebAssembly (and Zig)I found this aa bit late but this repo is interesting about WebAssembly and Zig: The most "surprising" facts are:
So how do I return an array? Even before returning an array, our type in Zig should correspond to the type declared in Javascript. Then, you send the address of its first element, and the size, and let Javascript build his UnitArray from this. You are really running WebAssembly functions that happen to be produced by Zig.
And there is or this short video if you prefer: WebAssembly outside the web. A bit into WebAssembly arcanes and running WebAssembly server-side ( |
The GitHub pages running Zig compiled into WebAssembly: https://ndrean.github.io/zig-assembly-test/ |
You saw me coming.... You write "elixirish webassemblish" code, as use Write WebAssembly with the power of Elixir as your compiler: Use Elixir’s module system to break problems down and then compose them together. |
Running WebAssembly in Elixir
To use WebAssembly in Elixir, you would typically:
WebAssembly and NIFNIF is generally faster and less latency and loads faster, but WebAssembly is safer because it is sandboxed.
NIFs require compilation for specific target platforms, while Wasm modules is portable. WebAssembly can therefor run edge functions safely. Example: passing strings to/from Elixir to WebAssemblyA WebAssembly function accepts only numbers, integers or floats. When calling a WebAssembly function and passing a string, you typically need to pass two parameters:
This is because WebAssembly does not have direct access to the host's memory. You also need to provide a memory allocation for WebAssembly (compiled from an internal
|
Challenge: Create a Mandelbrot set image with Zig
Introduction
WFT is a Mandelbrot set? 🤯
https://en.wikipedia.org/wiki/Mandelbrot_set
Nice, but how? 😳
https://en.wikipedia.org/wiki/Plotting_algorithms_for_the_Mandelbrot_set
Of course, some explanations on how to translate this is into real code.
But wait, why? 🧐
Well, the algorithm is "easy" to understand, once you understood what a Mandlebrot set is 😁.
What is interesting is that the computation lends itself extremely well to parallel processing. So
Go
of course, and maybeElixir
(usingNx
?) are candidates. Lets do it inZig
firstly, then maybeElixir
.Since we want to produce an image, we also learn to setup and use a Zig library to precisely produce a PNG file from data. It turns out that this is the easiest part (but also happened to be a blocking step).
Can I put this altogether and render an image? Lets
Zigler
it and letLivebook
run and display it!The text was updated successfully, but these errors were encountered: