Skip to content

Commit 8b0af9c

Browse files
committed
fix: release a plan's buffers after their last reader
run() held every kernel's output to the end-of-step barrier, so a step's footprint was the sum of everything the graph computes rather than what is live between two kernels, and the pool then kept that high-water mark for the process. The plan now records each position's last reader and run() drops the buffer there, which lets the pool hand the bytes to a later output in the same run. The reuse is safe on the asynchronous backend because kernels, memsets and commits all go down one in-order stream.
1 parent ed53abe commit 8b0af9c

3 files changed

Lines changed: 56 additions & 2 deletions

File tree

‎limn/jit.py‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ class Plan:
8484
buffers: tuple[int, ...] # the BUFFER positions, whose bytes come from this step's graph
8585
sinks: tuple[int, ...] # realized() resolved to positions, so alias chains are walked once
8686
positions: int # how many nodes the graph had: the size of a run's buffer table
87+
releases: tuple[tuple[int, ...], ...] # per call: the positions whose bytes no later call reads
8788

8889

8990
@dataclass(frozen=True, slots=True)
@@ -164,19 +165,29 @@ def run(self, plan: Plan, sources: Mapping[int, Buffer]) -> list[Buffer]:
164165
repointed at the fresh value so sinks read what was just written rather than a copy the
165166
commit never touched. execute() and capture.replay both come through here, so the
166167
transaction rule has one owner.
168+
169+
A position is dropped right after its last reader's call: the run's peak footprint is
170+
what is live between two kernels, not the sum of everything the graph computes, which
171+
for a training step is most of a backward pass held to the barrier for no reason. The
172+
reuse is safe on an asynchronous backend because kernels, memsets and commits all go
173+
down one in-order stream: whatever still reads the old bytes was enqueued first. A
174+
deferred value stays alive in `deferred` regardless, and a source buffer's bytes belong
175+
to its tensor, so dropping the reference here frees at most prepare()'s migrated copy.
167176
"""
168177
bufs: list[Buffer] = [None] * plan.positions
169178
for p, buf in sources.items():
170179
bufs[p] = self.prepare(buf)
171180
deferred: list[tuple[int, Buffer]] = []
172-
for call in plan.calls:
181+
for call, drop in zip(plan.calls, plan.releases, strict=True):
173182
outs = [self.out_alloc(nb, call.zero_fill) for nb in call.out_nbytes]
174183
call.fn([bufs[p] for p in call.inputs], outs)
175184
for p, out in zip(call.outputs, outs, strict=True):
176185
if p is not None: # a multi-output kernel writes this one, but nothing here reads it
177186
bufs[p] = out
178187
if call.assign_target is not None:
179188
deferred.append((call.assign_target, outs[0]))
189+
for p in drop:
190+
bufs[p] = None
180191
for p, value in deferred:
181192
self.commit(sources[p], value)
182193
bufs[p] = value
@@ -234,9 +245,19 @@ def plan_of(self, order: list[Node], position: dict[Node, int], sinks: list[Node
234245
assign_target=position[kernel.target] if root.op is Op.ASSIGN else None,
235246
)
236247
)
248+
# each position's last reader, so run() can drop it there; a sink is read after the
249+
# last call, and an assign value is deferred until the commit, but `deferred` holds
250+
# its own reference, so only the sinks need shielding from release
251+
sunk = tuple(position[realized(sink)] for sink in sinks)
252+
last_read = {p: k for k, call in enumerate(calls) for p in call.inputs}
253+
released: list[list[int]] = [[] for _ in calls]
254+
for p, k in last_read.items():
255+
if p not in sunk:
256+
released[k].append(p)
237257
return Plan(
238258
tuple(calls),
239259
tuple(p for p, node in enumerate(order) if node.op is Op.BUFFER),
240-
tuple(position[realized(sink)] for sink in sinks),
260+
sunk,
241261
len(order),
262+
tuple(map(tuple, released)),
242263
)

‎tests/test_backend_cuda.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,20 @@ def test_the_pool_recycles_dropped_buffers():
166166
dev.trim()
167167
assert not dev.pool
168168
assert dev._alloc(1024).ptr != 0
169+
170+
171+
def test_a_run_releases_each_link_of_a_chain_as_its_reader_runs():
172+
"""A chain of n kernels must run in the memory of a couple of links, not all of them.
173+
174+
Each gather cuts a kernel, so this is n calls each reading only the link before it.
175+
run() drops a buffer after its last reader and the pool hands the bytes to the next
176+
output, so two link-sized allocations circulate for the whole chain; without the
177+
release, every link stays live to the barrier and the pool ends holding all n."""
178+
dev = CudaDevice()
179+
rows = Tensor(np.arange(1 << 18, dtype=np.int32))
180+
y = Tensor(np.ones((1 << 18, 4), dtype=np.float32))
181+
for _ in range(8):
182+
y = (y + 1.0)[rows]
183+
out = dev.copyout(dev.execute([y.node])[0]).view(np.float32)
184+
np.testing.assert_array_equal(out, np.full(1 << 20, 9.0, dtype=np.float32))
185+
assert len(dev.pool.get(4 << 20, [])) <= 4

‎tests/test_jit.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,22 @@ def test_an_output_nothing_reads_still_gets_its_buffer():
257257
assert runs == ["pair"]
258258

259259

260+
@needs_cc
261+
def test_a_plan_releases_positions_after_their_last_reader():
262+
set_device("c")
263+
rows = Tensor(np.arange(8, dtype=np.int32))
264+
y = Tensor(randf(8, 2))
265+
for _ in range(3):
266+
y = (y + 1.0)[rows] # each gather cuts a kernel, so the chain is one call per link
267+
y.numpy()
268+
dev = device.active()
269+
assert isinstance(dev, CompiledDevice)
270+
plan = next(iter(dev.plans.values()))
271+
outs = [call.outputs[0] for call in plan.calls]
272+
assert outs[0] in plan.releases[1] and outs[1] in plan.releases[2]
273+
assert not any(s in drop for drop in plan.releases for s in plan.sinks)
274+
275+
260276
def test_on_an_interpreting_device_the_function_just_runs():
261277
data = batches(4)
262278
counter = {"ran": 0}

0 commit comments

Comments
 (0)