Skip to content

Perf/tolk compile time - #235

Draft
ghennequin wants to merge 6 commits into
raven-ml:mainfrom
hennequin-lab:perf/tolk-compile-time
Draft

ghennequin wants to merge 6 commits into
raven-ml:mainfrom
hennequin-lab:perf/tolk-compile-time

Conversation

@ghennequin

Copy link
Copy Markdown
Collaborator

WIP

A beam search timed a candidate by running it, so every sample compiled,
linked and allocated the candidate's program again, and a kernel an
earlier round compiled was compiled anew when a later round's action path
made it again.

Search.beam_search takes ~prepare in place of ~measure: a function that
prepares a program once and returns the sampler the search times it with,
up to three times. Tolk_engine.prepare compiles and links the program and
allocates its buffers once; Tolk_engine.measure is the first sample of a
prepared program. The search keeps a candidate's program across rounds and
reuses it when its kernel is reached again, while the compute filter and
the binaries already timed are decided anew each round.

The search suite's add_3d case compiles 58 programs for 50 distinct
kernels, where compiling each round's candidates anew takes 87.
A graph of index arithmetic and comparisons, as a jitted iLQR solve
builds, compiled in minutes while its eager run takes milliseconds. Two
passes walked a slice per node where a property of the slice answers.

- fold_where_closure asked whether a branch reaches its condition before
  its own INDEX gate, so every selection paid a walk of the branch that
  the gate then rejected. The cheap guards run first: the gate is the
  operation set a node reaches, kept per node and shared with a source
  that adds none.
- remove_bufferize toposorted the slice of a reduction's sources for
  every stage it considered, to ask whether it reads a buffer. Each node
  now keeps that property, shared with a source that reads one.

A differentiable iLQR solve of 20 steps unrolled six times compiled in
15 s where it took 56 s, its implicit gradient in 8 s where it took
52 s; a pendulum solve of 10 steps unrolled six times in 40 s where it
took 76 s, its gradient in 29 s where it took 79 s.
A candidate of BEAM_UOPS_MAX instructions or more was rendered, compiled
and only then dropped, and the candidates that reach the limit are the
unrolls whose compilation is slowest: comgr spent seconds on a candidate
the search had already decided against, while the round that timed it
waited on the compile.

Codegen.with_uops_cap caps a program's linearized instructions, domain
local since candidates compile on worker domains, and Codegen.do_linearize
raises Too_many_uops once linearizing reaches it, so neither the renderer
nor the compiler sees the program. Search.try_compile sets the cap from
BEAM_UOPS_MAX around to_program and drops the candidate the exception
names, where it before dropped it after the fact.

The lorenz example's step, cold: the compile work spent on candidates that
were dropped fell from 154 s to 30 s over its 97 searches, and the rounds
whose time was one candidate's compilation, from 12.3 s to about 2 s.
A candidate's sample ran its program again and again until the runs took
10 us in all, and every run paid a profile session of its own: its stamps,
a synchronization, and a calibration of the device's clock against the
host's that takes five stamped round trips of the device. A microsecond
kernel paid tens of sessions for one measurement.

A run's span is stamped by the device itself, so one run measures as well
as the mean of many: Tolk_engine.prepare returns one run's span as the
sample, and the search keeps the least of the three it takes. A clock's
calibration is kept for two seconds, per device: a span's length needs the
clock's rate, which a stale offset does not skew, and a stop whose last
calibration is recent calibrates nothing.

The lorenz example's step, cold: the candidates' timing fell from 66 s to
60 s over its 97 searches.
…only in buffers

A graph that reaches the same kernel twice — a forward pass run on two
samples, a layer a model uses once per block — searched and compiled it
once per reach: the beam cache's key was the kernel's own, and two kernels
that name different buffers differ in it though every shape, variable and
operation is the same.

The key now renames a kernel's parameters by their first appearance, so
kernels that differ only in which buffers they name share an answer. A
kernel of other sizes, variables or operations keeps its own key: the
renaming is a bijection on the parameters' slots, and the rest of the
kernel is the key.

Of the first 48 searches of the lorenz example's step, 3 are answered by
an earlier one; a graph that repeats a layer shares one per repeat.
…that are noise

A search spends seconds per kernel — a compile, a link and a load of the
device's code object for every candidate it tries — to save microseconds
on a kernel that is microseconds long already, and it keeps a round going
for an improvement smaller than the jitter of the timings it compares.

BEAM_SKIP_UNDER_US=n times the kernel itself in the first round, which
then holds it as a candidate the search can answer with, and stops after
that round when the kernel is under n microseconds. BEAM_MIN_PROGRESS_REL=f
stops a search whose best candidate improves on the best so far by less
than f of its own time, where BEAM_MIN_PROGRESS is that same bound in
seconds. Both are off by default: searching every kernel and every
improvement is what the beam has always done.

The lorenz example's step, cold: 20 and 0.005 take its jit from 457 s to
296 s, with steps of 1.6 to 1.9 ms and the same drift-norm trajectory; a
warm cache takes 3.2 s.
tmattio added a commit that referenced this pull request Oct 5, 2026
A beam search paid most of its time before it timed anything. Each of
a candidate's three samples called Tolk_engine.measure, which rebuilt
scratch buffers from the program's ELF signature, linked a schedule,
and averaged up to 1000 runs, printing a DEBUG=2 line for each. Equal
kernels reached by two sequences of actions were compiled twice, and a
candidate past BEAM_UOPS_MAX was rendered and compiled before being
dropped. PR #235 measured these costs on AMD: 12 to 16% duplicate
compiles, and 154 s of a lorenz_simple search compiling over-cap
candidates.

Tolk_engine.timer replaces measure and is staged as a search uses it:
applied to the kernel it allocates one buffer per storage parameter,
once per search and never for a cached one; applied to a program it
links it once over those buffers; each sample is one silent run.
Building the call from the kernel's parameters also removes the
Option.get on sparse globals. beam_search takes ~time, applies it to
its kernel before compiling, keeps each kernel's compilation by
Scheduler.ast, filled before a round is spread over domains, and
checks the uop cap on Codegen.linearize, a new stage that to_program
completes. ?cold goes: every caller asked for it.

The search still picks what tinygrad's picks: searches.golden and the
generator check are unchanged. On an M1 Max under load, a candidate's
setup on Metal is about 1.1-1.3 ms, from 3.5-4.3 ms. New search rows in
the bench record the link. The replay of a compiled call allocates
823 words, from 828, since a run walks its calls without a closure.
D115 and D116 record the compile changes.
tmattio added a commit that referenced this pull request Oct 5, 2026
tinygrad's search starts from an untimed kernel of infinite time and
compares the least of each program's timings. The least over hundreds
of noisy candidates is biased low, so a round continued, and a
candidate was kept alone at the end, on a gain within the noise. A
search could answer a kernel slower than the one it was given. PR #235
measured trailing rounds on 2 to 4% gains on AMD (25.96 to 25.42 us),
and first rounds that searched kernels already at their floor.

The search now compiles and times its kernel before its first round,
with no early stop, and starts the beam from it. A round progresses
iff the greatest sample of its fastest candidate plus
BEAM_MIN_PROGRESS is below the least sample of the beam's first
kernel; otherwise the search answers the beam's first kernel. One
comparison replaces tinygrad's three exits and the fastest kept alone.
The cost is the kernel's own compilation and three samples per search.

The golden generator applies the same rules, and searches.golden was
regenerated: every search chooses what it chose, with three more
samples. On the host, a search of a kernel as small as the exec
suite's answers the kernel itself, so that suite scales optimised
programs' samples down to keep checking what a searched program
computes. D117 records the departure.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant