aboutsummaryrefslogtreecommitdiff
path: root/cachelab-handout/helper/main.py
blob: 6be3fe5cd8357ae9d09ad46f8b666f2435b090e2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295

#!/usr/bin/env python3

"""
NOTA BENE
THIS IS FULLY DONE BY AI
DO NOT ASK ME WHY SOMEWHAT IS SOMEWHAT
THIS IS USED SOLELY FOR ILLUSTRATION AND EXPLANATION RATHER THAN ACTUAL COURSE WORK
THIS IS NOT CHEATING

Cache Lab 61x67: isolate the effect of the i2/j2 loop order.

This script models the cache used by the classic CS:APP Cache Lab:
    s = 5  -> 32 sets
    E = 1  -> direct mapped
    b = 5  -> 32-byte cache line = 8 int values

It compares two access orders for an 8x8 software tile:

    j2-i2: A is read vertically, B is written horizontally
    i2-j2: A is read horizontally, B is written vertically

The decisive control experiment is run in two modes:

    continuous:
        The cache is cold only at the start of the whole transpose.
        Cache state is preserved across software-tile boundaries.

    reset-per-tile:
        The cache is cleared before every software tile.
        This removes all cross-tile reuse while preserving the exact
        access order inside each tile.

For each order, the cross-tile benefit is:

    misses(reset-per-tile) - misses(continuous)

Default A/B addresses model the common Cache Lab harness where A and B are
separate 256x256 static int arrays. Their distance is 256*256*4 bytes, an
integer multiple of the 1 KiB cache capacity, so A and B have the same cache
set alignment.
"""

from __future__ import annotations

import argparse
from dataclasses import dataclass
from typing import Iterable, Iterator, Literal

Order = Literal["j2-i2", "i2-j2"]


@dataclass(frozen=True)
class Config:
    m: int = 61                 # A columns, B rows
    n: int = 67                 # A rows, B columns
    tile: int = 8
    sets: int = 32
    line_bytes: int = 32
    int_bytes: int = 4
    a_base: int = 0
    b_base: int = 256 * 256 * 4

    def validate(self) -> None:
        values = {
            "m": self.m,
            "n": self.n,
            "tile": self.tile,
            "sets": self.sets,
            "line_bytes": self.line_bytes,
            "int_bytes": self.int_bytes,
        }
        for name, value in values.items():
            if value <= 0:
                raise ValueError(f"{name} must be positive, got {value}")
        if self.line_bytes % self.int_bytes != 0:
            raise ValueError("line_bytes must be divisible by int_bytes")


@dataclass
class Stats:
    a_hits: int = 0
    a_misses: int = 0
    b_hits: int = 0
    b_misses: int = 0

    @property
    def total_hits(self) -> int:
        return self.a_hits + self.b_hits

    @property
    def total_misses(self) -> int:
        return self.a_misses + self.b_misses


class DirectMappedCache:
    """Minimal direct-mapped cache: one tag per set."""

    def __init__(self, sets: int, line_bytes: int) -> None:
        self.sets = sets
        self.line_bytes = line_bytes
        self.tags: list[int | None] = [None] * sets

    def clear(self) -> None:
        self.tags = [None] * self.sets

    def access(self, address: int) -> bool:
        """Return True on hit and False on miss; install line on every access."""
        block_number = address // self.line_bytes
        set_index = block_number % self.sets
        tag = block_number // self.sets

        hit = self.tags[set_index] == tag
        self.tags[set_index] = tag
        return hit


def address_a(cfg: Config, row: int, col: int) -> int:
    return cfg.a_base + cfg.int_bytes * (row * cfg.m + col)


def address_b(cfg: Config, row: int, col: int) -> int:
    return cfg.b_base + cfg.int_bytes * (row * cfg.n + col)


def tile_elements(cfg: Config, i: int, j: int, order: Order) -> Iterator[tuple[int, int]]:
    """Yield A[row][col] coordinates in the exact loop order."""
    if order == "j2-i2":
        for j2 in range(cfg.tile):
            for i2 in range(cfg.tile):
                row = i + i2
                col = j + j2
                if row < cfg.n and col < cfg.m:
                    yield row, col
    elif order == "i2-j2":
        for i2 in range(cfg.tile):
            for j2 in range(cfg.tile):
                row = i + i2
                col = j + j2
                if row < cfg.n and col < cfg.m:
                    yield row, col
    else:
        raise ValueError(f"unsupported order: {order}")


def simulate(cfg: Config, order: Order, reset_per_tile: bool) -> Stats:
    cache = DirectMappedCache(cfg.sets, cfg.line_bytes)
    stats = Stats()

    for i in range(0, cfg.n, cfg.tile):
        for j in range(0, cfg.m, cfg.tile):
            if reset_per_tile:
                cache.clear()

            for a_row, a_col in tile_elements(cfg, i, j, order):
                # Original code performs one A load followed immediately by one B store.
                if cache.access(address_a(cfg, a_row, a_col)):
                    stats.a_hits += 1
                else:
                    stats.a_misses += 1

                # Transpose: A[a_row][a_col] -> B[a_col][a_row]
                if cache.access(address_b(cfg, a_col, a_row)):
                    stats.b_hits += 1
                else:
                    stats.b_misses += 1

    return stats


def row(label: str, stats: Stats) -> str:
    return (
        f"{label:<22}"
        f"{stats.a_misses:>11}"
        f"{stats.b_misses:>11}"
        f"{stats.total_misses:>14}"
    )


def print_report(cfg: Config) -> None:
    results: dict[tuple[Order, str], Stats] = {}
    for order in ("j2-i2", "i2-j2"):
        results[(order, "continuous")] = simulate(cfg, order, reset_per_tile=False)
        results[(order, "reset")] = simulate(cfg, order, reset_per_tile=True)

    print("Cache Lab loop-order control experiment")
    print("=" * 69)
    print(
        f"matrix A={cfg.n}x{cfg.m}, B={cfg.m}x{cfg.n}, "
        f"tile={cfg.tile}x{cfg.tile}"
    )
    print(
        f"cache: {cfg.sets} sets, direct mapped, "
        f"{cfg.line_bytes} B/line, {cfg.line_bytes // cfg.int_bytes} ints/line"
    )
    print(f"A base = {cfg.a_base:#x}, B base = {cfg.b_base:#x}")
    print()

    print(f"{'case':<22}{'A misses':>11}{'B misses':>11}{'total misses':>14}")
    print("-" * 58)
    for order in ("j2-i2", "i2-j2"):
        print(row(f"{order} continuous", results[(order, "continuous")]))
        print(row(f"{order} reset/tile", results[(order, "reset")]))
        print()

    print("Cross-tile benefit = reset-per-tile misses - continuous misses")
    print(f"{'order':<12}{'A benefit':>12}{'B benefit':>12}{'total':>12}")
    print("-" * 48)

    benefits: dict[Order, tuple[int, int, int]] = {}
    for order in ("j2-i2", "i2-j2"):
        continuous = results[(order, "continuous")]
        reset = results[(order, "reset")]
        a_benefit = reset.a_misses - continuous.a_misses
        b_benefit = reset.b_misses - continuous.b_misses
        total_benefit = reset.total_misses - continuous.total_misses
        benefits[order] = (a_benefit, b_benefit, total_benefit)
        print(f"{order:<12}{a_benefit:>12}{b_benefit:>12}{total_benefit:>12}")

    reset_gap = (
        results[("j2-i2", "reset")].total_misses
        - results[("i2-j2", "reset")].total_misses
    )
    continuous_advantage = (
        results[("i2-j2", "continuous")].total_misses
        - results[("j2-i2", "continuous")].total_misses
    )
    extra_a_cross_tile = benefits["j2-i2"][0] - benefits["i2-j2"][0]
    extra_b_cross_tile = benefits["j2-i2"][1] - benefits["i2-j2"][1]

    print()
    print("Derived comparison")
    print("-" * 69)
    print(f"Per-tile-only gap (j2-i2 - i2-j2): {reset_gap:+d} misses")
    print(
        "Extra cross-tile benefit of j2-i2: "
        f"A={extra_a_cross_tile:+d}, B={extra_b_cross_tile:+d}"
    )
    print(
        "Final continuous-cache advantage of j2-i2: "
        f"{continuous_advantage} fewer misses"
    )

    # These equalities are the point of the experiment for the default setup.
    if cfg == Config():
        assert results[("j2-i2", "continuous")].a_misses == 847
        assert results[("j2-i2", "continuous")].b_misses == 1081
        assert results[("j2-i2", "reset")].a_misses == 1223
        assert results[("j2-i2", "reset")].b_misses == 1081
        assert results[("i2-j2", "continuous")].a_misses == 877
        assert results[("i2-j2", "continuous")].b_misses == 1238
        assert results[("i2-j2", "reset")].a_misses == 1062
        assert results[("i2-j2", "reset")].b_misses == 1238
        assert continuous_advantage == 187
        assert extra_a_cross_tile == 191
        assert extra_b_cross_tile == 0


def parse_int(text: str) -> int:
    """Accept decimal or 0x-prefixed integers."""
    return int(text, 0)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Compare i2/j2 loop orders with and without cross-tile cache state."
    )
    parser.add_argument("--m", type=int, default=61)
    parser.add_argument("--n", type=int, default=67)
    parser.add_argument("--tile", type=int, default=8)
    parser.add_argument("--sets", type=int, default=32)
    parser.add_argument("--line-bytes", type=int, default=32)
    parser.add_argument("--int-bytes", type=int, default=4)
    parser.add_argument("--a-base", type=parse_int, default=0)
    parser.add_argument("--b-base", type=parse_int, default=256 * 256 * 4)
    return parser


def main() -> None:
    args = build_parser().parse_args()
    cfg = Config(
        m=args.m,
        n=args.n,
        tile=args.tile,
        sets=args.sets,
        line_bytes=args.line_bytes,
        int_bytes=args.int_bytes,
        a_base=args.a_base,
        b_base=args.b_base,
    )
    cfg.validate()
    print_report(cfg)


if __name__ == "__main__":
    main()