Skip to content

Commit ddc68b0

Browse files
feat(resolver): cache specializations by (target file, binding tuple) (#63)
Two call sites that resolve to the same parametric sub-circuit with the same width-binding tuple now share a single specialized ir.Module instead of producing duplicate bodies. The cache lives for the duration of one project resolution and is keyed by a flat string "{target_file_id}@{w0},{w1},..." so std.StringHashMap can carry it without a custom hasher. The positional encoding is load-bearing: (4, 2) and (2, 4) for a <W, S> callee are distinct keys even though their value multisets match, so a multi-param caller cannot accidentally collapse two semantically different specializations. Four tests pin the behavior in place: * Identical single-param bindings produce one shared module. * Distinct single-param bindings produce two separate modules. * Multi-param identical tuples share. * Multi-param distinct tuples (same values, different positions) do not share. Engine bench golden matches; the cache only changes resolver-level structural sharing, never the topology or simulation result.
1 parent e17402c commit ddc68b0

10 files changed

Lines changed: 162 additions & 0 deletions

File tree

lib/resolver/resolve_bodies.zig

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,22 @@ const SpecResult = struct {
148148
sources: std.ArrayList([]const u8),
149149
};
150150

151+
fn buildSpecializationKey(
152+
allocator: std.mem.Allocator,
153+
target_file_id: u32,
154+
bindings: []const single_resolver.WidthBinding,
155+
) ![]u8 {
156+
var buf: std.ArrayList(u8) = .{};
157+
errdefer buf.deinit(allocator);
158+
const writer = buf.writer(allocator);
159+
try writer.print("{d}@", .{target_file_id});
160+
for (bindings, 0..) |b, idx| {
161+
if (idx > 0) try buf.append(allocator, ',');
162+
try writer.print("{d}", .{b.value});
163+
}
164+
return buf.toOwnedSlice(allocator);
165+
}
166+
151167
fn specializeCallSites(
152168
allocator: std.mem.Allocator,
153169
modules_list: *std.ArrayList(ir.Module),
@@ -157,6 +173,17 @@ fn specializeCallSites(
157173
spec: *SpecResult,
158174
diagnostic_list: *diagnostics.DiagnosticList,
159175
) !void {
176+
// Cache keyed by "{target_file_id}@{w0},{w1},...". Two call sites with
177+
// the same target and the same width-binding tuple share one specialized
178+
// module instead of duplicating the body. Keys are owned by the cache
179+
// and freed on scope exit.
180+
var spec_cache = std.StringHashMap(u32).init(allocator);
181+
defer {
182+
var it = spec_cache.iterator();
183+
while (it.next()) |entry| allocator.free(entry.key_ptr.*);
184+
spec_cache.deinit();
185+
}
186+
160187
const original_file_count = file_paths.len;
161188
for (asts, 0..) |maybe_caller_ast, caller_file_id_usize| {
162189
if (caller_file_id_usize >= original_file_count) break;
@@ -229,13 +256,21 @@ fn specializeCallSites(
229256
if (!ok) continue;
230257
}
231258

259+
const cache_key = try buildSpecializationKey(allocator, target_file_id, bindings.items);
260+
if (spec_cache.get(cache_key)) |cached_file_id| {
261+
allocator.free(cache_key);
262+
ir_comp.kind.sub_circuit_ref.specialized_target_file = .{ .value = cached_file_id };
263+
continue;
264+
}
265+
232266
const spec_file_id: u32 = @intCast(modules_list.items.len);
233267
const specialized = single_resolver.resolveWithBindings(
234268
allocator,
235269
target_ast,
236270
spec_file_id,
237271
bindings.items,
238272
) catch |err| {
273+
allocator.free(cache_key);
239274
std.debug.print("specialization failed for '{s}': {s}\n", .{ ast_inst.type_name.text, @errorName(err) });
240275
continue;
241276
};
@@ -249,6 +284,8 @@ fn specializeCallSites(
249284
try spec.paths.append(allocator, synthetic_path);
250285
try spec.sources.append(allocator, "");
251286

287+
try spec_cache.put(cache_key, spec_file_id);
288+
252289
ir_comp.kind.sub_circuit_ref.specialized_target_file = .{ .value = spec_file_id };
253290
}
254291
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import wide_not "wide_not.circ"
2+
input[4] x
3+
input[8] y
4+
wide_not inst_a[4](a=x)
5+
wide_not inst_b[8](a=y)
6+
output[4] out_a(in=inst_a.o)
7+
output[8] out_b(in=inst_b.o)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
input<W>[W] a
2+
not[W] inv(in=a)
3+
output[W] o(in=inv.out)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
input<W, S>[W] data
2+
input<W, S>[S] select
3+
not[W] data_inv(in=data)
4+
not[S] sel_inv(in=select)
5+
output[W] data_out(in=data_inv.out)
6+
output[S] sel_out(in=sel_inv.out)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import mux "mux_lib.circ"
2+
input[4] x1
3+
input[2] s1
4+
input[2] x2
5+
input[4] s2
6+
mux inst_a[4, 2](data=x1, select=s1)
7+
mux inst_b[2, 4](data=x2, select=s2)
8+
output[4] a_data(in=inst_a.data_out)
9+
output[2] a_sel(in=inst_a.sel_out)
10+
output[2] b_data(in=inst_b.data_out)
11+
output[4] b_sel(in=inst_b.sel_out)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
input<W, S>[W] data
2+
input<W, S>[S] select
3+
not[W] data_inv(in=data)
4+
not[S] sel_inv(in=select)
5+
output[W] data_out(in=data_inv.out)
6+
output[S] sel_out(in=sel_inv.out)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import mux "mux_lib.circ"
2+
input[4] x1
3+
input[2] s1
4+
input[4] x2
5+
input[2] s2
6+
mux inst_a[4, 2](data=x1, select=s1)
7+
mux inst_b[4, 2](data=x2, select=s2)
8+
output[4] a_data(in=inst_a.data_out)
9+
output[2] a_sel(in=inst_a.sel_out)
10+
output[4] b_data(in=inst_b.data_out)
11+
output[2] b_sel(in=inst_b.sel_out)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import wide_not "wide_not.circ"
2+
input[4] x
3+
input[4] y
4+
wide_not inst_a[4](a=x)
5+
wide_not inst_b[4](a=y)
6+
output[4] out_a(in=inst_a.o)
7+
output[4] out_b(in=inst_b.o)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
input<W>[W] a
2+
not[W] inv(in=a)
3+
output[W] o(in=inv.out)

tests/resolver/resolve_bodies_test.zig

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,74 @@ test "parameter binding order follows source declaration, not reference" {
226226
try std.testing.expectEqual(@as(u8, 4), lookupPinWidth(spec, "data_out", .output).?);
227227
try std.testing.expectEqual(@as(u8, 8), lookupPinWidth(spec, "aux_out", .output).?);
228228
}
229+
230+
fn countSpecializations(project: @import("ir_types").Project) usize {
231+
var count: usize = 0;
232+
for (project.file_paths) |path| {
233+
if (std.mem.startsWith(u8, path, "<specialization:")) count += 1;
234+
}
235+
return count;
236+
}
237+
238+
fn countCallSitesTargeting(project: @import("ir_types").Project, expected_target_file_id_min: u32) usize {
239+
// Count caller-side sub_circuit_ref components whose specialized_target_file
240+
// points at some appended specialization (file_id >= original_file_count).
241+
var count: usize = 0;
242+
for (project.files) |module| {
243+
for (module.components) |comp| {
244+
switch (comp.kind) {
245+
.sub_circuit_ref => |ref| {
246+
if (ref.specialized_target_file) |target| {
247+
if (target.value >= expected_target_file_id_min) count += 1;
248+
}
249+
},
250+
else => {},
251+
}
252+
}
253+
}
254+
return count;
255+
}
256+
257+
test "cache: identical bindings share one specialization" {
258+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
259+
defer arena.deinit();
260+
const allocator = arena.allocator();
261+
262+
// Two call sites at width 4 → cache produces exactly one specialization
263+
// module despite two sub_circuit_ref components pointing into it.
264+
const project = try resolveProject(allocator, fixtureRoot("shared_spec"));
265+
try std.testing.expectEqual(@as(usize, 1), countSpecializations(project));
266+
try std.testing.expectEqual(@as(usize, 2), countCallSitesTargeting(project, @intCast(project.file_paths.len - 1)));
267+
}
268+
269+
test "cache: distinct bindings produce separate specializations" {
270+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
271+
defer arena.deinit();
272+
const allocator = arena.allocator();
273+
274+
// Call sites at width 4 and width 8 → cache misses on the second key
275+
// and produces two distinct specialization modules.
276+
const project = try resolveProject(allocator, fixtureRoot("distinct_bindings"));
277+
try std.testing.expectEqual(@as(usize, 2), countSpecializations(project));
278+
}
279+
280+
test "cache: multi-param identical bindings share one specialization" {
281+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
282+
defer arena.deinit();
283+
const allocator = arena.allocator();
284+
285+
// Two call sites at (W=4, S=2) → one shared specialization.
286+
const project = try resolveProject(allocator, fixtureRoot("multi_param_shared"));
287+
try std.testing.expectEqual(@as(usize, 1), countSpecializations(project));
288+
}
289+
290+
test "cache: multi-param distinct binding tuples are not shared" {
291+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
292+
defer arena.deinit();
293+
const allocator = arena.allocator();
294+
295+
// (4, 2) vs (2, 4) are distinct cache keys even though they share the
296+
// same width values — order matters.
297+
const project = try resolveProject(allocator, fixtureRoot("multi_param_distinct"));
298+
try std.testing.expectEqual(@as(usize, 2), countSpecializations(project));
299+
}

0 commit comments

Comments
 (0)