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:

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.

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.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.

Why a separate program, not a shared library

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.

Timeouts and leftovers

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.