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 spec only grades its exit code. 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 compiler from OCaml

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.

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

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

For hex input, Int32.of_string accepts the full 32-bit unsigned range and reinterprets it as a signed int32.

-1l: all 32 bits set is -1 in two's complement. That's what lets Int32.of_string ("0x" ^ bits) 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. What happened?

Sys.command returns 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.