Back to course

A Dependency Is a Constraint, Not a Cost

24 minadvancedModule 2: Dependencies and the Front EndContinues from exe-latency
24 min · Module 2: Dependencies and the Front End · Advanced

Why This Matters

The last concept measured a ratio — 2.7× between dependent and independent execution — without saying what a dependency is. That is backwards, and this concept puts it the right way round: an instruction costs a latency because something constrains when it may start, and the cost is a consequence of the constraint rather than a property of the instruction.

There is a second thing a dependency can constrain, and this course found it by accident. Two byte-identical loops in one binary, differing only in their address, measured 0.79 and 2.13 ticks per iteration. No data dependency explains that, because the code is the same code. What differs is where the front end finds it.

$ objdump -d cycbench | grep -A9 -E '<b_dep1>:|<b_ind1>:'
0000000000002c40 <aligned_0>:
0000000000002cc0 <branchless>:
0000000000003840 <b_dep1>:

$ ./noise2
  64-byte aligned body               min 1.0607  max 1.2190  spread 14.9%
  same body at offset 32             min 0.9956  max 1.2104  spread 21.6%

So this concept has two halves that look unrelated and are not: a data dependency is a constraint on when a register may be read, and an address is a constraint on when a block of bytes may be fetched. Both are the same kind of thing — a rule that says “not yet” — and both are invisible in the instruction stream.

A Simple Model

What a dependency is, in the smallest useful terms:

A DEPENDENCY is a claim that instruction B cannot read its input until instruction A has produced it. RAW read after write A writes r, B reads r -- the real one WAR write after read B reads r, A writes r WAW write after write A writes r, B writes r RAW is the dependency you cannot rename away. It is a fact about the DATA. WAR and WAW are artefacts of reusing a register name. Give each write its own name and they vanish -- which is exactly what register renaming does, in hardware, at rename time, for free. so: the hardware removes WAR and WAW by renaming, and RAW survives. that is the entire reason out-of-order execution is possible and also its entire limit.

Which gives the rule that decides performance, and it is a one-liner:

an instruction may start when its inputs are ready, not when the previous instruction finished. so what limits a straight-line region is the LONGEST chain of RAW dependencies in it, not the number of instructions. 8 dependent adds longest chain = 8 -> 8 latencies 8 independent adds longest chain = 1 -> 1 latency, and the other 7 overlap it

Now the second half, which is the same shape applied to bytes rather than registers. The front end has to fetch instructions, and it fetches them in blocks that begin at boundaries determined by the address. A loop that sits entirely inside one block is fetched efficiently; one that straddles a boundary needs two. The block size is not a matter of opinion — the instrument concept measured the cache line at 64 bytes from getconf and the same number out of /sys.

THE ADDRESS IS A DEPENDENCY TOO. the front end cannot start fetching a block until it knows where the block starts, and the block starts are fixed by the address. so: same code, different address, different fetch behaviour, different time. measured, up to 2.5x, with no difference in the instruction stream at all. and unlike a data dependency, this one is fixed at LINK time and cannot be renamed away. the hardware cannot undo where the linker put things.

That asymmetry is the point worth carrying forward. A register dependency is a property of the code and the hardware works around it by renaming. An address dependency is a property of the link and the hardware cannot work around it at all. Which means the linker is a performance tool, whether or not it knows it — and the linker-script course has been placing sections without ever having had a timing in its model.

The Address Measurement, and What It Does Not Explain

One loop body, byte for byte identical, emitted at eight offsets within a 64-byte boundary:

$ ./cycbench 2>&1 | sed -n '/7. ALIGNMENT/,$p'
  7. ALIGNMENT: one body, seven addresses
  -------------------------------------
     The SAME 16-byte loop (add; dec; jnz; mov), byte for byte,
     preceded by identical padding. Only the address changes.

  body                                  min ticks     vs floor
  loop at offset  0 mod 64                 0.7645        1.15x
  loop at offset  8 mod 64                 0.8334        1.26x
  loop at offset 16 mod 64                 0.7716        1.16x
  loop at offset 24 mod 64                 0.8274        1.25x
  loop at offset 32 mod 64                 1.3533        2.04x
  loop at offset 40 mod 64                 0.6638        1.00x
  loop at offset 48 mod 64                 0.8394        1.26x
  loop at offset 56 mod 64 (CROSSES)       0.8554        1.29x

     fastest 0.6638   slowest 1.3533   ratio 2.04x

2.04×, for identical instructions. That is the finding, and it is larger than anything in the latency table — bigger than the dependent-versus-independent ratio, bigger than the cost of the operations themselves. Where the linker puts a loop can matter more than what the loop does.

And here is where the honesty has to be, because the obvious story is wrong. The loop body is 12 bytes, so offsets 0–52 fit inside one 64-byte line and offset 56 does not. If crossing a line boundary were the whole mechanism, offset 56 would be the slow row. It is not — offset 32 is, and offset 40 is the fastest of all.

measured: offset 32 is slowest (2.04x) offset 40 is fastest (1.00x) offset 56, the one that CROSSES, is 1.29x so the cost is NOT a simple function of offset mod 64, and this course does not claim a mechanism. plausible and UNMEASURED: an operation cache with its own indexing a loop stream detector per-address branch predictor state the decode/rename boundary width all four would explain a non-periodic pattern. none of them was measured here, and naming them is not the same as knowing one of them is the answer.

So the effect is claimed and the cause is not. That is the whole discipline in one measurement, and it is worth being explicit about why it is the right call rather than a cowardly one: a mechanism you have guessed is a mechanism a reader will act on, and acting on a wrong one costs them an afternoon. An admitted unknown costs them nothing but the question.

And the practical consequence is immediate and does not need the mechanism. Every bench function in the artifact is marked aligned(64), and making that change was the single largest improvement to the instrument — it is what turned the latency table from a set of numbers that contradicted each other into monotonic series. The rest of this course’s numbers exist because of that one attribute.

The Loop Dependency, and the Fix That Is Not Obvious

The other dependency in the measurement was there from the start and hid everything for several iterations. Every loop carries one, in its own counter.

the loop's own chain: dec -> flags -> jnz -> next dec ^ | +----------------------------+ length 1. the body must fit in its shadow. with ONE operation in the body: loop overhead 1.109 ticks + one add 1.084 ticks <- LESS the add is not free. it is FREE, because the branch was the bottleneck and the add hid underneath it. and "less than the empty loop" is the tell.

The fix is to unroll, and it is worth being precise about which unrolling, because there are two and they fix different things.

unroll the BODY -- 8 operations, 1 iteration loop cost / 8. the body dominates and the marginal cost per operation becomes the operation's own. THIS is what the latency table does. unroll the LOOP -- 1 operation, 8 iterations same arithmetic, but now you have divided the dec/jnz chain by 8 as well, and the loop can overlap its OWN body better. both work, and the second is why hand- optimised inner loops are unrolled in multiples of the SIMD width: the loop cost has to amortise, and it amortises over the number of operations the core can retire at once.

There is a third dependency hiding in the same measurement, and finding it is what made the flags section honest. The adc in the branchless variant reads the carry flag that the ror before it wrote. That is a RAW dependency on a register with no rename:

$ ./cycbench 2>&1 | sed -n '/4. THE FLAGS/,/5. BRANCHES/p'
  body                                  min ticks     vs floor
  loop floor                               0.6630        1.00x
  1 TEST  (WRITE flags only)               0.7385        1.11x
  2 TEST  (WRITE flags only)               0.6833        1.03x
  4 TEST  (WRITE flags only)               0.9582        1.45x
  4 ROR   (a CHAIN on one register)        3.5259        4.23x
  1 ROR+ADC (read AFTER write)             0.9427        1.13x
  2 ROR+ADC (read AFTER write)             1.7822        2.14x
  4 ROR+ADC (read AFTER write)             3.7130        4.46x

Read the third row and the fifth. Four TESTs, which all write the flags, cost 1.45× the floor — nearly free. Four RORs cost 4.23× — and the difference is that they all target one register, so they are an ordinary RAW chain of length 4.

And the first explanation this course wrote down was wrong. It said the flags register has no rename, so flag-writing instructions serialise. The measurement says otherwise. So the obvious replacement was tried — “reading the flags is what costs” — and four ROR+ADC pairs measured 4.46× against 4.23× for the RORs alone, a difference of 1.05×. Also wrong.

Three candidate explanations, two retracted, and the survivor is the boring one: it was always an ordinary dependency, and the flags were a red herring. That is recorded in the artifact’s own output and asserted by the crosscheck, so it cannot quietly come back.

Try It Yourself

$ cd courses/exe/assets/samples
$ ./cycbench 2>&1 | sed -n '/4. THE FLAGS/,/5. BRANCHES/p'
$ ./cycbench 2>&1 | sed -n '/7. ALIGNMENT/,$p'
$ python3 crosscheck.py 2>&1 | sed -n '/^H /,/^I /p'
$ python3 crosscheck.py 2>&1 | sed -n '/^I /,$p'

Then go looking for dependencies in code you did not write:

  1. Classify every instruction in a real loop as
     RAW, WAR or WAW, by hand. Take something
     small:

       objdump -d --section .text yourprog \
         | sed -n '/<yourfunc>:/,/^$/p'

     For each pair (A, B) ask: does B read a
     register A wrote?  That is RAW and it is the
     only kind that survives renaming.

     Now do the same for the ADDRESSES: which
     blocks does this loop span, and does it cross
     a 64-byte boundary? (You can count with the
     ISA course's decoder -- it reports offsets,
     and 0x40 bytes apart is a line.)

  2. Break a WAR/WAW on purpose and show the core
     does not care. Take a register that is written
     twice in a row and give the two writes
     different destinations:

       mov rax, 1
       mov rax, 2        # WAW on rax
       ret

       mov rbx, 1        # the second write goes
       mov rax, 2        # elsewhere: no WAW

     Time both. (Expect no difference at this
     size -- the effect of WAW elimination is real
     but you need enough of them to see it. Build
     a loop with 64 WAW pairs and compare with 64
     RAW pairs; the ratio is the renaming benefit
     and it should be close to the width.)

  3. Find a RAW chain that is not obvious, which
     is the kind that actually costs time:

       a = b + c;  d = a * e;  f = d - g;

     Three operations, but a LONGEST chain of 3,
     not 3 independent ones. Now:

       a = b + c;  d = x * e;  f = y - g;

     Same operation count, longest chain 1. This
     is the whole optimisation and it is what a
     compiler does when it reassociates floating
     point -- which it may NOT do without
     -ffast-math, because it changes the result.
     That is a dependency bought with correctness,
     and it is worth finding one in a compiler
     you use.

  4. Now measure the address effect properly, at
     all 64 offsets, and see whether YOUR curve
     is periodic:

       for (off = 0; off < 64; off++)
         build a binary with a loop at that offset
         time it

     (cycbench does 8 of the 64 with one binary.
     Doing all 64 needs 64 builds, or one build
     with 64 copies at 64 offsets. Either way,
     the shape is the result. This machine's
     curve is NOT periodic in 64 -- it is not
     periodic at all -- and your front end may
     well be different. The METHOD is what
     transfers: identical bytes, varying address,
     and treat the curve as a measurement of the
     front end rather than an explanation of it.)

  5. Finally, connect the two halves, because they
     are one thing and this is the exercise that
     shows it. Write a loop where the data
     dependency is short AND the address is bad,
     and one where each is the opposite, and
     confirm the two effects are additive rather
     than one masking the other.

       // short chain, well aligned
       for (i = 0; i < n; i++) { a[i] = b[i] + c[i]; }

       // long chain, well aligned
       for (i = 0; i < n; i++) { s = s * 3 + 1; }

       // short chain, badly aligned
       //   (pad the loop to start at offset 32)

     If the effects are additive you will see
     three distinct times. If the bad alignment
     MASKS the dependency cost, the two short-
     chain versions will measure the same -- and
     that would be the more interesting result,
     because it would mean the front end, not the
     data path, is the limit for a large class
     of real code.

Exercise 5 is the one that unifies the concept, and its outcome is genuinely uncertain, which is why it is worth doing. If the address effect and the dependency effect are additive, this course has two independent stories. If they mask each other, the front end dominates and the latency table in module 1 describes a machine that most real code never reaches. Either answer is worth having, and the measurement is twenty lines long.

Exercise 2 is the one that makes renaming real rather than a slogan. The ratio between a WAW-heavy loop and a RAW-heavy loop of the same length is the renaming benefit, and it should come out close to the execution width from the latency concept — two completely separate measurements landing on the same number, which is the kind of agreement the collection treats as evidence.

Connect

The connection to the linker-script course is the practical payload of this concept, and it is a debt that course could not even see. That course found the default script’s section placement rules and their effect on the output, and had no reason to ask whether the placement cost anything — because nothing in the format records a time. This concept supplies the missing term: section alignment is not tidiness, it is up to a factor of 2.5 in execution time for a loop that happens to straddle a boundary, and it is fixed at link time in a way the hardware cannot undo. Every linker discussion in the collection up to this point has been about what goes where; this is the first measurement of what where costs.

The connection to the PIC-cost concept is a genuine trade the two courses disagree about, and the disagreement is the point. That course measured nine instructions against seventeen for the same computation and concluded PIC costs more. This concept supplies the reason that conclusion may be wrong: seventeen instructions with a longer chain is not necessarily slower than nine with a shorter one, because the extra instructions may be independent. Both are measurable, neither was measured against the other, and the resolution needs a timing experiment that spans both courses. The encoding-limits concept is the other half of that argument, since it is the disp32 that makes the extra instruction necessary.

Two connections outward. The SIB concept measured the one-byte instruction that exists so a compiler can write base + index*scale without a scratch register, and explicitly said it saves about twelve bytes and two instructions. This concept is the measurement that makes that saving worth having: two instructions that do not destroy a register are two more independent operations, and the marginal cost of an independent operation was measured at about a quarter of a dependent one. So the ISA course’s encoding feature and this course’s execution measurement are the same fact seen from the compiler’s side and the CPU’s, and each is useless without the other.

And the connection to the object course’s oracle loop is methodological rather than technical, and it is the reason this concept exists in the shape it does. That concept established the discipline of checking a value against a source that did not produce it. Here the discipline produced the course’s single most useful result by accident — the apparent noise turned out to be a fixed per-address penalty — and it did so because a variance figure is a claim about your setup before it is a claim about your machine, and nobody had looked. The check that would have caught it was the obvious one: are these two functions byte-identical? They were not supposed to differ, and they did not — which is exactly what made the difference visible.

One limit, and it bounds the concept on both sides. The address effect is measured and its mechanism is not, so this concept can say “the address of a loop is worth up to 2.5×” and not why; and the data-dependency half is measured on integer add in a synthetic loop, so the latency is this machine’s integer-add latency and nothing more general. No floating point, no vector unit, no multiply, and no memory operand was measured — the last of those is where the real limit usually lives, and it is the subject of the memory-hierarchy course.

render_lesson_js(&mut page)