I’ve been reading The C Programming Language by K & R

Two chapters in and it’s clear that my lack of Freshman CS is gonna sting. All fun and games until we start moving bits around. OK, let’s catch up, and make it fast.

This arc was partially inspired by Casey Muratori’s rants on performant code, and a deep desire to escape LLM type-checked, 8 fallback elifs, 20,000 lines of bloated dashboard hell. I also just, love things that go fast.

Who am I kidding; I use Arch, btw, worship Luke Smith, and voluntarily ran a Desktop that requires you to recompile whenever you want change a color. This was a natural next step.

By the end of this post I am poorly attempting to flex on GNU core utils with the speed of my 70 line C program that eats russian literature at ~16 million letters per second.

For now, let’s just get something to print.

Start with one character

#include <stdio.h>

int main(int argc, char *argv[]) {
  int c;

  c = getchar();

  if (c != EOF) {
    printf("%c\n", c);
  }

  return 0;
}

Neat. Now, short of plagiarizing Dennis Ritchie’s examples or rewriting a GNU core util, let’s build something useful. I know, this is ASCII, I am positive I can google or prompt my 4b param model on my phone anything I am about to learn, but after 2 chapters I get the feeling that this binary stuff is going to be on the test.

First decision was to shirk stdin in favor of command line arguments. I envision my future code breaking because of obscure i=0101101, i++ errors where a step in the wrong direction causes inexplicable downstream behavior. I need a resource to quickref my missing mental model of characters.

We’re holding a char c in our hands, and we can print it to stdout, but we know that is just a representation of a value. some int, that itself is a friendly abstraction of the number represented as a byte. Let’s output that:

Build the binary column

void print_row(char c) {
  char symbol[8];
  char binary[9];
  int i;

  for (i = 7; i >= 0; i--) {
    binary[7 - i] = ((c >> i) & 1) ? '1' : '0';
  }
  binary[8] = '\0';

  printf("%-8s %-5u 0x%02X   %s\n", symbol, c, c, binary);
}

Instantiate a place to put the symbolic output e.g A or SPACE and then create an array of char with 9 places for our byte of char data. 8 for the ASCII text, and one for the null terminator.

Next we need to read our first bit and walk backwards down our array, pushing the bit to the right with c » i until we finally set our null terminator.

> ./char A
symbol   value hex    binary
A        65    0x41   01000001

Wow great this kinda works.

Control characters need names

The first rough version fell apart on \n and \t, and I had some constants with a switch() but I hated the code. ASCII has a lot more oddball control codes and such than I had ever paid attention to. After looping through 0-127 it seems like 0-31 are mostly special characters that dont print well like \n or ACK. Barring me discovering some inherent “name” data stuffed into these 8 bits, or a library to do this for me, I had to result to a lookup table:

static const char *ASCII_CONTROL_NAMES[] = {
    "NUL", "SOH", "STX", "ETX", "EOT", "ENQ",  "ACK", "BEL", "BS",
    "HT",  "LF",  "VT",  "FF",  "CR",  "SO",   "SI",  "DLE", "DC1",
    "DC2", "DC3", "DC4", "NAK", "SYN", "ETB",  "CAN", "EM",  "SUB",
    "ESC", "FS",  "GS",  "RS",  "US",  "SPACE"};

static const char *symbol_label(unsigned char c, char symbol[8]) {
  if (c <= 32) {
    return ASCII_CONTROL_NAMES[c];
  }

  if (c == 127) {
    return "DEL";
  }

  if (c <= 126) {
    symbol[0] = (char)c;
    symbol[1] = '\0';
  } else {
    snprintf(symbol, 8, "0x%02X", (unsigned)c);
  }

  return symbol;
}

Paired with some optimizations to our initial binary assignment and putting that in its own function outside of printrow(), we can now get consistent formatting when we pass a char, or a set of chars to our program. It’s iterating through the characters provided by walking through the bytes.

> ./char A
symbol   value hex    binary
A        65    0x41   01000001
> ./char Chan
symbol   value hex    binary
C        67    0x43   01000011
h        104   0x68   01101000
a        97    0x61   01100001
n        110   0x6E   01101110
> ./char "The C Programming Language"
symbol   value hex    binary
T        84    0x54   01010100
h        104   0x68   01101000
e        101   0x65   01100101
SPACE    32    0x20   00100000
C        67    0x43   01000011

Let it read anything

Last thing I want, is to skip the “”, get rid of the arguments safety check and just have this thing read any and all text given to it. And while we’re at it let’s make sure it can be fed output from a file, that seems useful. Only slightly concerned about flying too close to the sun here. Based on my reading of the code, it should be able to handle any number of characters as long as the shell doesn’t break the input first.

int main(int argc, char *argv[]) {
  printf("%-8s %-5s %-6s %s\n", "symbol", "value", "hex", "binary");

  if (argc == 1) {
    if (!isatty(STDIN_FILENO)) {
      int c;

      while ((c = getchar()) != EOF) {
        print_row((unsigned char)c);
      }
    } else {
      for (int i = 0; i < 128; i++) {
        print_row((unsigned char)i);
      }
    }
  } else {
    for (int i = 1; i < argc; i++) {
      if (i > 1) {
        print_row((unsigned char)' ');
      }
      for (int j = 0; argv[i][j] != '\0'; j++) {
        print_row((unsigned char)argv[i][j]);
      }
    }
  }

  return 0;
}

For the main function, we print our header row with printf, then handle three major cases. In the event no arguments are provided it will; check for stdin and call printrow() over that, else it will just print our default ASCII table for values 0-127 (great to grep or visualize the relationships). When there is 1 or more argument provided, the shell splits the chars into multiple arguments based on the spaces, so we handle that by, printing a row of SPACE artificially in between each argument, and then continuing to print the row for each byte in the string until we reach the null terminator. This effectively makes it so ./char A B CC creates the same output as ./char "A B CC".

> ./char A B 'CC'
symbol   value hex    binary
A        65    0x41   01000001
SPACE    32    0x20   00100000
B        66    0x42   01000010
SPACE    32    0x20   00100000
C        67    0x43   01000011
C        67    0x43   01000011

OK yea that’s sick. and fast.

Benchmarks

> wc char.c
  71  229 1564 char.c

71 lines, not too shabby. Let’s feed it itself, see if the program can handle new lines, quotes and C syntax.

> ./char < char.c
symbol   value hex    binary
#       35    0x23   00100011
i        105   0x69   01101001
n        110   0x6E   01101110
c        99    0x63   01100011
l        108   0x6C   01101100
u        117   0x75   01110101
d        100   0x64   01100100
e        101   0x65   01100101
SPACE    32    0x20   00100000
<        60    0x3C   00111100
s        115   0x73   01110011

do it got a hemi?

>  hyperfine --warmup 1000 --input char.c './char'
Benchmark 1: ./char
  Time (mean ± σ):     123.6 µs ±  81.1 µs    [User: 208.8 µs, System: 134.3 µs]
  Range (min … max):    61.5 µs … 831.9 µs    4251 runs

µs……

Light travels 37km in the time it takes for our compiled code to parse itself. That’s 23 meters per byte parsed.

Gonna Need a Bigger Boat

We are gonna need a bigger boat.jpg

Now I understand why Jeff Geerling spent the past year of his life procuring clocks.

> ls -lah war_and_peace.txt
Permissions Size User Date Modified Name
.rw-r--r--  3.4M chan  2 Mar 04:30   war_and_peace.txt

> wc war_and_peace.txt
  66041  566333 3359613 war_and_peace.txt

Thank you project gutenberg, 3mil char should do.

Half of me wants to download 40TB llm training dataset off of hugging face but we’ll have to settle for some open source Russian Literature.

> hyperfine --warmup 30 './char < war_and_peace.txt'
Benchmark 1: ./char < war_and_peace.txt
  Time (mean ± σ):     239.7 ms ±   1.4 ms    [User: 237.2 ms, System: 2.0 ms]
  Range (min … max):   237.9 ms … 241.9 ms    12 runs

240 milliseconds. Couldn’t be more pleased. We aren’t solving world hunger or loading up pandas to reverse a string, I know the code is simple, but that’s the point right? We learned how to interact with the basic blocks in a way that’s widely applicable and blazing fast. It’s doing slightly more than just adding a tick to a counter, it’s producing meaningful output in hopefully an efficient way, and helped me get comfortable with the syntax and some basic ideas of C.

> ./char < war_and_peace.txt | sed -n '1,2p;150000,150009p'
symbol   value hex    binary
0xEF     239   0xEF   11101111
n        110   0x6E   01101110
i        105   0x69   01101001
c        99    0x63   01100011
e        101   0x65   01100101
SPACE    32    0x20   00100000
c        99    0x63   01100011
l        108   0x6C   01101100
e        101   0x65   01100101
a        97    0x61   01100001
n        110   0x6E   01101110

….nice, clean

No actually. That is the 150,000th char in War and Peace. Clone and try yourself, that was my first run, those exact 9 char.

Also, did you catch that? The 1st byte? We asked for it with sed -n 1 and it returned the symbol 0xEF. That’s not a letter… ah, yes. As expected. This is a Unicode. A UTF-8 Byte Order Mark, telling anything reading the file to expect unicode. Of course, 8 bits leaves 256 possible int values that can be represented, and our code only covers 0-127, the ASCII set. We’ll have to address UTF-8 another time.

Comparisons

bateman.jpg

Let’s see Paul Allen’s card….

wc, the glorified +1 machine, absolutely smokes me.

> hyperfine --warmup 30 'wc war_and_peace.txt'
Benchmark 1: wc war_and_peace.txt
  Time (mean ± σ):       7.4 ms ±   0.3 ms    [User: 7.0 ms, System: 0.5 ms]
  Range (min … max):     7.0 ms …   8.9 ms    384 runs

cat is even worse. orders of magnitude.

> hyperfine -N --warmup 30 'cat war_and_peace.txt'
Benchmark 1: cat war_and_peace.txt
  Time (mean ± σ):     283.8 µs ±  58.3 µs    [User: 203.3 µs, System: 55.4 µs]
  Range (min … max):   239.0 µs … 1058.4 µs    7363 runs

In an attempt to optimize, I recompiled char.c with gcc -03 -march=native and was able to shave off 40ms. Further optimizations would require making the code less fun, like not formatting the bytes each loop, and I have elected to ignore those implementations in the name of science.

> hyperfine --warmup 30 --input war_and_peace.txt './char'
Benchmark 1: ./char
  Time (mean ± σ):     199.3 ms ±   3.6 ms    [User: 196.9 ms, System: 1.8 ms]
  Range (min … max):   195.8 ms … 207.2 ms    15 runs

Sub 200ms. Count it.

Would Casey be proud for a Day 1 program? Or at this scale it’s all trivial; I should try doing something actually complex before I start flexing online about solving the software performance crisis. Seems like I should put down K+R and read the source for cat

Next steps

The rust rewrite is calling me. My first attempt did not handle memory correctly and showed me just how efficient this C program is.

As for our current program, the last column is intriguing. It looks… fast. I wonder what all we can do with it. Potentially group repeated phrases, build some compression, and then maybe a tokenizer from scratch??? We’ll see.

Or maybe I’ve wasted a day, C will soon be an artifact only found in computer science museums and we will just feed War and Peace into Claude directly to vibe spew a table of binary and hex of 3 million bytes.

So it goes.

git repo