HC+

HolyC, fixed where it hurts.

HC+ is our improved HolyC. It keeps the language, the live compiler and the edit-and-run loop, and fixes the handful of things that cost us the most: speed, precedence, a missing keyword, crashes and tooling.

Status: a plan backed by measurements. HC+ is not released; items marked Planned are not built yet.

Principles

Compatible by default

Existing HolyC keeps its meaning. New behaviour is switched on for our own code and switched off again before the prompt returns.

Keep the live loop

The compiler rebuilds and hot-loads in about three seconds on the live CD. Editing and running stays instant. No transpiler, no LLVM front end.

Measure first

Every item below carries a number from the same VM, or says it is an estimate.

Fixing precedence.

The most common source of quiet bugs when moving code between C and HolyC. Drag the sliders.

One expression, two readings: a*b>>1
HolyC today, shift first: a*(b>>1)
HC+ with C precedence: (a*b)>>1

In HolyC the shift operators bind tighter than multiplication. HC+ switches the compiler to C order around our own code, so code ported from C means what it says.

Faster, with numbers.

Measured in the same VM
Blend, per pixel
Float multiply-add
Rebuild the compiler

Blend: naive loop against two pixels a pass with register variables. Float: x87 against SSE2. The last row compares a full OS rebuild with rebuilding only the compiler in the running system; the 150 s figure is a rough comparison, not a measurement.

The change list.

Twelve changes, filtered by area.

#ChangeWhat it doesEffortEvidence
1Hot loops as pointer walksWrite per-pixel loops as pointer walks in small functions, two pixels a pass. The blend loop falls from 11 ns to 0.8 ns a pixel.SMeasured
2SSE2 for floating pointEnable the SSE registers once at start-up and move float kernels to SSE2: 67 ns to 6 ns a multiply-add under emulation.SMeasured
3Test harness over serialBoot or restore the snapshot, send a test file, read results over the serial port, assert from Python.SPrototype exists
4C precedence, switched inThe precedence table is runtime data. Sixteen assignments switch the live compiler to C order, sixteen restore it.SVerified
5continueAdd the missing keyword to the statement parser. Loops already carry a break label.SPlanned
6Catchable faults for the desktopA page fault in an app becomes an exception the desktop catches, so a bad pointer drops one frame instead of opening the debugger.MPlanned
7Peephole passesDrop a redundant move, fold address arithmetic, reuse constants: about 1.4x on integer loops.MEstimated
8Sampling profilerA timer-driven sampler that reports where frames go, without guessing.MPlanned
9SSE2 in the compiler backendFloat code fast everywhere, without hand assembly: 8 to 11x on F64.LPlanned
10Register arguments for flagged functionsCheaper calls for callbacks and widgets: about 2x on call-heavy paths.MEstimated
11Host lint and formatterA checker for mixed operators, ASCII-only source and unescaped dollar signs, and a token-based indenter.MPlanned
12Debug allocator and bounds checksCanaries around allocations and checked array indexes in debug builds.SPlanned

Effort: S is days, M is weeks, L is a project. Ranked by payoff for effort.

A small keyword.

I64 i;
for (i=0;i<n;i++) {
  if (!Keep(i))
    goto skip;   // no continue
  Work(i);
skip:;
}

A goto for every skip

There is no continue, so skipped iterations need labels, and each label is another place to slip.

I64 i;
for (i=0;i<n;i++) {
  if (!Keep(i))
    continue;
  Work(i);
}

The keyword you expected

The loop parsers already carry a break label, so adding a continue label is a small change. Planned, not built.

Pleasant to write, pleasant to read.

Speed and safety make HC+ better. These changes make it nicer to live in. Each one is judged by what it removes from real desktop code.

Bool LpMatch(I64 a)
{
  I64 i,j;
  U8 *s=ks_an[a];
  if (!lp_ql) return TRUE;
  for (i=0;s[i];i++) {
    for (j=0;j<lp_ql && s[i+j] && LpLow(s[i+j])==LpLow(lp_q[j]);j++);
    if (j==lp_ql) return TRUE;
  }
  return FALSE;
}
Bool LpMatch(I64 a)
{
  if (!lp_ql) return TRUE;
  s := ks_an[a];
  for (i in 0..StrLen(s)) {
    j := 0;
    while (j<lp_ql && s[i+j] && LpLow(s[i+j])==LpLow(lp_q[j])) j++;
    if (j==lp_ql) return TRUE;
  }
  return FALSE;
}

Before, then with HC+

Range loops

The declaration block and the loop header shrink to what you mean.

No I64 i,j; block, and the empty-bodied inner for becomes a while that says what it does. := declares a variable where it is first used and takes its type from the value.

for (n=0;n<lp_nv;n++) {
  target=1.0;
  if (id==ID_LP+n || (n==lp_sel && id<ID_LP)) target=1.16;
  old=lp_sc[n];
  if (old<1.0) old=1.0;
  if (!ks_anim) lp_sc[n]=target;
  else lp_sc[n]=old+(target-old)*0.35;
  if (AbsI64((lp_sc[n]-old)*1000)>0) LpDirtyTile(n);
}
for (n in 0..lp_nv) {
  hot    := id==ID_LP+n || (n==lp_sel && id<ID_LP);
  target := hot ? 1.16 : 1.0;
  old    := lp_sc[n] < 1.0 ? 1.0 : lp_sc[n];
  lp_sc[n] = ks_anim ? old+(target-old)*0.35 : target;
  if (Abs(lp_sc[n]-old) > 0.001) LpDirtyTile(n);
}

Before, then with HC+

Ternary and names

Five lines of set, test, set again become four lines that read in order.

Naming hot makes the intent obvious, and the AbsI64(x*1000)>0 trick becomes a plain comparison. HolyC has no ?: today.

if (lp_ql) StrPrint(buf,"%s",lp_q); else StrPrint(buf,"Type to search");
RRFill(x,MBAR+52,w,36,18,0xFFFFFF,40*fa/256);
LIcon(L_GRID,x+22,MBAR+70,16,0xF4F4F6,fa);
Txt(x+40,MBAR+60,F_REG,0xB8B8C4,buf,fa);
#define LP_TEXT #F4F4F6
#define LP_DIM  #B8B8C4

text := lp_ql ? lp_q : "Type to search";
RRFill(x, MBAR+52, w, 36, 18, #FFFFFF, 40*fa/256);
LIcon(L_GRID, x+22, MBAR+70, 16, LP_TEXT, fa);
Txt(x+40, MBAR+60, F_REG, LP_DIM, text, fa);

Before, then with HC+

Colour literals

Colours look like colours, and repeated values get names.

The buf[80] array existed only to get a string pointer, so it and the if/else go away. About 40 hex literals in this one file would become named or recognisable colours.

if (ks_wtdraw) (*ks_wtdraw)();
...
if (ks_wtframe) (*ks_wtframe)(dt);
ks_wtdraw?.();
...
ks_wtframe?.(dt);

Before, then with HC+

Optional calls

A hook that may or may not be set takes one token, not two checks.

The launchpad file has four of these hooks already. ?. means "call it if it is set".

U0 LpMove(I64 d)
{
  I64 old=lp_sel;
  if (!lp_nv) return;
  lp_sel=ClampI64(lp_sel+d,0,lp_nv-1);
  Print("LpMove d=%d old=%d new=%d (03g:%d)\n",d,old,lp_sel,__LINE__);
  if (old!=lp_sel) { LpDirtyTile(old); LpDirtyTile(lp_sel); }
}
U0 LpMove(I64 d)
{
  if (!lp_nv) return;
  old := lp_sel;
  lp_sel = Clamp(lp_sel+d, 0, lp_nv-1);
  dbg d, old, lp_sel;   // d=1 old=2 lp_sel=3  (03g-launchpad.HC:158)
  if (old != lp_sel) { LpDirtyTile(old); LpDirtyTile(lp_sel); }
}

test "LpMove stays inside the list" {
  lp_nv = 5; lp_sel = 4;
  LpMove(3);
  assert lp_sel == 4;
}

Before, then with HC+

dbg and test

Debugging and tests become one line, and live next to the code.

dbg prints names, values and file:line from one line. A test block is run by the serial test harness and stripped from release builds.

The "before" code is taken from desk3/03g-launchpad.HC. The "after" uses proposed syntax: none of it exists yet.

What other languages do well

Go: boring on purpose

One formatter with no options, a small language, and tooling that ships with it. HC+ takes the formatter and the restraint.

Rust: the compiler teaches

Errors that show the line, the reason and a fix. This is the most transferable idea, and patching the compiler's messages is cheap with the three-second reload.

Ruby: reads like prose

Blocks, methods on values and readable literals. HC+ takes lambdas, method calls on structs and units such as 250ms, but not the magic.

Writing both side by side.

HC+ is plain HolyC plus a few extra tokens. A host pass rewrites the extras and passes everything else through byte for byte.

Both dialects in one project
desk3/01-core.HC          copied as-is
desk3/03g-launchpad.HCP   --> hcpp --> 03g-launchpad.HC (generated)
                                         |
                      send-to-vm.sh ships every .HC to the VM

The extension picks the dialect

.HC files are never touched. .HCP files go through a host-side pass and come out as ordinary HolyC. Files convert one at a time and the build never breaks halfway.

Only invalid HolyC is rewritten

Tokens such as :=, ?., dbg and test do not compile today, so a plain .HC file cannot be changed by accident. Strings, comments and DolDoc $ commands are skipped.

Line numbers never move

Every rewrite stays on its original line, so a compiler error at line 158 points at line 158 of the source, with no source maps.

The proof, and the sharp edges

  • The proof: run the pass over every existing .HC file and require the output to equal the input. If that passes, original HolyC is valid HC+ by construction.
  • New keywords can collide with existing names such as a variable called test. The pass warns on any collision.
  • .. next to ...: HolyC already uses case 1...5, so the lexer must treat both explicitly.
  • Method calls are the hard one. Class members can be function pointers, so obj.fn(x) already means something. A text pass cannot tell them apart, so the first version would use a distinct spelling such as rect:Contains(p).
  • Editing generated code on the VM loses the sugar. That is the weakness of this route, and the reason features should move into the compiler once they have proved worth keeping.

Roadmap

First week

Items 1 to 5: hot loops, SSE2 kernels, the serial test harness, C precedence and continue. All are small and each can be checked with the benchmark set.

Six months

A lightly patched compiler carried as a small patch series, hot kernels assembled on the host, host-side lint, formatter and benchmarks, and a fault policy for the desktop.

Not doing

No LLVM front end, no HolyC-to-C transpiler and no ARM kernel. The gain over fixed HolyC is a few times at best, and the cost is the live edit loop.

Source: the repository note Improving HolyC on all of its deficits, which has the measurements, the compiler locations and what could not be verified. Numbers here are from that note; read it before relying on them.

Learn the base language first.

HC+ is HolyC with fixes, so the reference applies.