Week 2 ยท Lesson 5
The runtime
Rendering gives you C source. A runtime turns that source into answers: write it to a file next to a generated main, compile it, run the program, and read the buffers back out of its output, bit for bit.
Why bother this week
Week 2's run may still interpret the graph. The grader only sees its exit code and stderr. But from week 6 on your kernels get timed, and an interpreter that walks the graph per element can't compete with compiled C. The plan for the rest of the course is that your compiler compiles, and the C does the work. This week you build the plumbing while the programs are still small enough to check by hand.
A runtime has five steps. Step through them with one program: add 0.2 to the first element of an f32 buffer.
Generating main
The kernel only does the work. The program you compile also needs a main that sets up the buffers, calls kernel, and prints the results. Your compiler writes that main too, from the same graph:
- One array per
buffer, sized from the op and filled from itsdata(or zeros).staticarrays keep large buffers off the stack. - A call to
kernelwith the arrays in slot order. - One output line per element, in a format you can parse without losing anything.
That last point matters for f32. printf("%f") prints -0.0 as -0.000000 and a tiny subnormal as 0.000000, and %g drops digits. Print the 32 bits instead, as 8 hex digits. Nothing can get lost, and reading them back is one function call.
A float's bits, not its value
Write uint32_t f32_bits(float x), returning the 32 bits that represent x: f32_bits(1.0f) is 0x3f800000. No undefined behavior.
Calling the C compiler from your compiler
You need three things from the standard library: somewhere to put files, a way to run a command, and a way to read a file back.
tempfile.TemporaryDirectory()creates a fresh, empty directory. Used aswith tempfile.TemporaryDirectory() as d:, it deletes the directory and everything in it when the block ends, even if something inside raises.os.path.join(d, "kernel.c")builds paths inside it.subprocess.runruns a command given as a list of arguments, so paths with spaces need no quoting.capture_output=Truecollects stdout and stderr,text=Truedecodes them to strings, andtimeout=stops a program that runs too long.- The result's
.returncodeis the exit code, and.stdoutand.stderrhold the output.
r = subprocess.run(["clang", "-O2", "my file.c", "-o", "a.out"], capture_output=True, text=True, timeout=30)
if r.returncode != 0:
print(r.stderr, file=sys.stderr) # clang's own message points at the bad line
Filename.temp_dir "uir" ""(OCaml 5.1 and later) creates a fresh, empty directory and returns its path.Filename.concat dir "kernel.c"builds paths inside it.Filename.quote_commandbuilds a command line with every argument quoted correctly for your platform, including paths with spaces. Its~stdoutargument redirects output into a file.Sys.commandruns that command line and returns the exit code.
Filename.quote_command "clang" ~stdout:"out.txt" ["-O2"; "my file.c"; "-o"; "a.out"]
(* "'clang' '-O2' 'my file.c' '-o' 'a.out' >'out.txt'" on Linux *)
Check every exit code. A compile error from clang is your renderer's bug, so show clang's message. A nonzero exit from the program means the kernel crashed.
The fastest-looking design compiles the kernel into a .so or .dll, loads it into your compiler's process, and calls it directly. In OCaml that means the ctypes and ctypes-foreign packages, libffi, and describing every C type from OCaml.In Python that means the ctypes module, describing every argument's C type, and keeping buffers alive while C uses them. It works, but it's a lot of machinery, and a crashing kernel takes your compiler down with it. A separate program costs a few milliseconds of process startup and gives you isolation and plain-text output for free. Start there. You can always switch later.
Sys.command waits forever. A kernel with an infinite loop, or one that faults on Windows and sits in the error reporter, will hang your compiler. If you need a timeout, the unix library (add unix to your dune libraries) has Unix.create_process and Unix.waitpid. And delete what you write: Fun.protect ~finally:(fun () -> ...) runs your cleanup even when something in the middle raises.
Without timeout=, subprocess.run waits forever. A kernel with an infinite loop, or one that faults on Windows and sits in the error reporter, will hang your compiler. With it, a slow program raises subprocess.TimeoutExpired, which you can catch and report. And delete what you write: the with form of TemporaryDirectory cleans up even when something in the middle raises.
Reading results back exactly
The program's output is text like 0 3e99999a: a slot, a space, and 32 bits. Turn each line back into a float by its bits, never by parsing a decimal. Int32.of_string ("0x" ^ bits) reads the hex (even with the top bit set, 0x80000000 becomes Int32.min_int), and Int32.float_of_bits reinterprets it.int(bits, 16) reads the hex as a number from 0 to 232 โ 1, struct.pack("<I", ...) turns it into 4 bytes, and struct.unpack("<f", ...)[0] reinterprets them as a float. Watch for Windows line endings: lines end in \r\n there.
Code it
Read a buffer back from the harness
The compiled program prints one line per f32 element: the slot number, a space, and the element's 32 bits as 8 hex digits, like 0 3f800000. Write read_slot : string -> int -> float list, returning slot k's values in output order. Lines may end in \r\n, blank lines can appear, and a slot that never appears gives [].
Check yourself
Why print f32 results as hex bits instead of with printf("%g")?
The grader compares f32 results bit for bit. How many digits does %g print, and how many does it take to pin down an f32?
The hex bits are the value, exactly: sign of zero, every mantissa bit, subnormals, NaN. %g rounds to 6 significant digits, and 9 are needed to round-trip an f32.
What are the 32 bits of -0.0f, as 8 hex digits?
Zero has an all-zero exponent and mantissa. Only one bit differs between 0.0f and -0.0f.
80000000: just the sign bit. 0.0f is 00000000. That one bit is why recip -0.0 is -inf, and why the runtime must keep it.
What does OCaml's Int32.of_string "0xffffffff" return?What does struct.unpack("<i", struct.pack("<I", int("ffffffff", 16)))[0] return? ("<i" is a signed 32-bit int.)
For hex input, Int32.of_string accepts the full 32-bit unsigned range and reinterprets it as a signed int32.int("ffffffff", 16) is 4294967295. Packing it as unsigned and unpacking the same bytes as signed reinterprets the bits.
-1l-1: all 32 bits set is -1 in two's complement. That's what lets Int32.of_string ("0x" ^ bits)the same bytes, unpacked as "<f", read any f32 bit pattern, including ones with the sign bit set.
Your runtime compiles the kernel and runs the program as a separate process. What's the main thing you give up compared with loading the kernel as a shared library?
Both designs run the same compiled C. What extra work does starting a new process add?
Process startup time. The arithmetic is the same machine code either way, and the separate process works everywhere clang does. In exchange you skip ctypes and libffi, and a crashing kernel can't crash your compiler.
Sys.command (Filename.quote_command "clang" [...]) returns 1.subprocess.run(["clang", ...]).returncode is 1. What happened?
That number is the command's exit code. What does a nonzero exit code from a compiler mean?
A nonzero exit code from clang means it didn't produce a program, almost always because of an error in the C. Print clang's own message: it points at the line your renderer got wrong.