@driftengine/texture 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +29 -0
- package/README.md +106 -0
- package/dist/decodeCpu.d.ts +59 -0
- package/dist/decodeCpu.js +234 -0
- package/dist/decodeGraph.d.ts +105 -0
- package/dist/decodeGraph.js +180 -0
- package/dist/half.d.ts +24 -0
- package/dist/half.js +86 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +55 -0
- package/dist/inference.d.ts +53 -0
- package/dist/inference.js +243 -0
- package/dist/materialArray.d.ts +38 -0
- package/dist/materialArray.js +40 -0
- package/dist/mipNdf.d.ts +29 -0
- package/dist/mipNdf.js +53 -0
- package/dist/overlay/journal.d.ts +78 -0
- package/dist/overlay/journal.js +171 -0
- package/dist/overlay/sparse.d.ts +68 -0
- package/dist/overlay/sparse.js +212 -0
- package/dist/progressive.d.ts +30 -0
- package/dist/progressive.js +56 -0
- package/dist/residency/pageCache.d.ts +103 -0
- package/dist/residency/pageCache.js +184 -0
- package/dist/residency/predict.d.ts +55 -0
- package/dist/residency/predict.js +51 -0
- package/dist/residency/predictor.d.ts +16 -0
- package/dist/residency/predictor.js +44 -0
- package/dist/residency/queue.d.ts +26 -0
- package/dist/residency/queue.js +52 -0
- package/dist/residency/stream.d.ts +66 -0
- package/dist/residency/stream.js +142 -0
- package/dist/residency/table.d.ts +36 -0
- package/dist/residency/table.js +72 -0
- package/dist/residency/viewTiles.d.ts +108 -0
- package/dist/residency/viewTiles.js +419 -0
- package/dist/semantics.d.ts +52 -0
- package/dist/semantics.js +76 -0
- package/dist/tensor/architecture.d.ts +53 -0
- package/dist/tensor/architecture.js +96 -0
- package/dist/tensor/attention.d.ts +5 -0
- package/dist/tensor/attention.js +62 -0
- package/dist/tensor/denseOperators.d.ts +2 -0
- package/dist/tensor/denseOperators.js +136 -0
- package/dist/tensor/graph.d.ts +83 -0
- package/dist/tensor/graph.js +175 -0
- package/dist/tensor/linear.d.ts +49 -0
- package/dist/tensor/linear.js +136 -0
- package/dist/tensor/operatorKit.d.ts +27 -0
- package/dist/tensor/operatorKit.js +45 -0
- package/dist/tensor/operators.d.ts +3 -0
- package/dist/tensor/operators.js +24 -0
- package/dist/tensor/resize.d.ts +6 -0
- package/dist/tensor/resize.js +107 -0
- package/dist/tensor/reuse.d.ts +33 -0
- package/dist/tensor/reuse.js +59 -0
- package/dist/tensor/shapeOperators.d.ts +3 -0
- package/dist/tensor/shapeOperators.js +173 -0
- package/dist/tensor/spatial.d.ts +34 -0
- package/dist/tensor/spatial.js +131 -0
- package/dist/tensor/spatialOperators.d.ts +2 -0
- package/dist/tensor/spatialOperators.js +138 -0
- package/dist/tileHash.d.ts +29 -0
- package/dist/tileHash.js +50 -0
- package/dist/timeNodes.d.ts +26 -0
- package/dist/timeNodes.js +48 -0
- package/package.json +59 -0
- package/src/decodeCpu.ts +308 -0
- package/src/decodeGraph.ts +214 -0
- package/src/half.ts +86 -0
- package/src/index.ts +175 -0
- package/src/inference.ts +278 -0
- package/src/materialArray.ts +67 -0
- package/src/mipNdf.ts +63 -0
- package/src/overlay/journal.ts +218 -0
- package/src/overlay/sparse.ts +275 -0
- package/src/progressive.ts +60 -0
- package/src/residency/pageCache.ts +233 -0
- package/src/residency/predict.ts +74 -0
- package/src/residency/predictor.ts +62 -0
- package/src/residency/queue.ts +78 -0
- package/src/residency/stream.ts +194 -0
- package/src/residency/table.ts +89 -0
- package/src/residency/viewTiles.ts +553 -0
- package/src/semantics.ts +114 -0
- package/src/tensor/architecture.ts +140 -0
- package/src/tensor/attention.ts +75 -0
- package/src/tensor/denseOperators.ts +153 -0
- package/src/tensor/graph.ts +244 -0
- package/src/tensor/linear.ts +153 -0
- package/src/tensor/operatorKit.ts +76 -0
- package/src/tensor/operators.ts +28 -0
- package/src/tensor/resize.ts +140 -0
- package/src/tensor/shapeOperators.ts +173 -0
- package/src/tensor/spatial.ts +178 -0
- package/src/tensor/spatialOperators.ts +182 -0
- package/src/tileHash.ts +60 -0
- package/src/timeNodes.ts +60 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decoded pages, and the switch that decides when the decoding happens.
|
|
3
|
+
*
|
|
4
|
+
* **This carries a mode because the spec's §9 names the risk rather than hoping about it.** If
|
|
5
|
+
* per-sample neural decode turns out too slow in the general case, the fallback is to decode once
|
|
6
|
+
* when a tile becomes resident and sample it as an ordinary texture thereafter — which loses
|
|
7
|
+
* procedural and per-sample parameterisation for the affected materials and keeps the compression,
|
|
8
|
+
* the joint channels and the streaming. Building the switch now costs a field and a branch.
|
|
9
|
+
* Retrofitting it into a pipeline that assumes per-sample decode everywhere costs a great deal,
|
|
10
|
+
* and would be attempted on the day somebody discovered the frame time, which is the worst day.
|
|
11
|
+
*
|
|
12
|
+
* **A page acquired this frame is never evicted.** The same rule `table.ts` exists to enforce, one
|
|
13
|
+
* level down and for the same reason: reclaiming a page the current frame is sampling produces a
|
|
14
|
+
* frame drawn against bytes somebody else is writing, which is a corruption rather than a stall
|
|
15
|
+
* and does not look like a streaming problem at all. A cache with nothing else to give refuses —
|
|
16
|
+
* `acquirePage` returns `-1` — because a wrong page is worse than no page.
|
|
17
|
+
*
|
|
18
|
+
* **What was evicted is reported, and the plan's signature had nowhere to say it.** A residency
|
|
19
|
+
* table that still calls a tile resident after its page has been reused points at a slot another
|
|
20
|
+
* tile now owns. The eviction has to reach the table, so it is drained rather than dropped.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Where the decode happens. See the header for which risk the second one is the answer to. */
|
|
24
|
+
export type DecodeMode = 'per-sample' | 'on-residency';
|
|
25
|
+
|
|
26
|
+
/** How a page's bytes are produced. Supplied by the caller: a cache cannot know a format. */
|
|
27
|
+
export type PageDecode = (hash: string, into: Uint8Array) => void;
|
|
28
|
+
|
|
29
|
+
export interface PageCacheOptions {
|
|
30
|
+
readonly mode?: DecodeMode;
|
|
31
|
+
/**
|
|
32
|
+
* What fills a page in `on-residency` mode.
|
|
33
|
+
*
|
|
34
|
+
* Optional, because a cache in `per-sample` mode never decodes and a test of the eviction policy
|
|
35
|
+
* has nothing to decode. Absent in `on-residency` mode, a page is acquired undecoded and
|
|
36
|
+
* `pageDecoded` says so rather than the cache pretending.
|
|
37
|
+
*/
|
|
38
|
+
readonly decode?: PageDecode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PageCache {
|
|
42
|
+
readonly pages: number;
|
|
43
|
+
readonly pageBytes: number;
|
|
44
|
+
/** Every page, back to back. Slot `n` is `[n * pageBytes, (n + 1) * pageBytes)`. */
|
|
45
|
+
readonly bytes: Uint8Array;
|
|
46
|
+
/** Which hash holds each slot, or `''` where it is free. */
|
|
47
|
+
readonly holder: string[];
|
|
48
|
+
/** Whether each slot's bytes have been decoded into. */
|
|
49
|
+
readonly decoded: boolean[];
|
|
50
|
+
slotOf: Map<string, number>;
|
|
51
|
+
/** Monotonic, standing in for time, so eviction needs no clock. As `table.ts` does. */
|
|
52
|
+
touchedAt: Map<string, number>;
|
|
53
|
+
clock: number;
|
|
54
|
+
/** The clock at the start of the frame. Nothing touched after it may be evicted. */
|
|
55
|
+
frameStart: number;
|
|
56
|
+
mode: DecodeMode;
|
|
57
|
+
decode: PageDecode | null;
|
|
58
|
+
/** Hashes whose pages have been taken since the caller last drained this. */
|
|
59
|
+
evicted: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createPageCache(
|
|
63
|
+
pages: number,
|
|
64
|
+
pageBytes: number,
|
|
65
|
+
options: PageCacheOptions = {},
|
|
66
|
+
): PageCache {
|
|
67
|
+
const count = Math.max(1, Math.floor(pages));
|
|
68
|
+
const size = Math.max(1, Math.floor(pageBytes));
|
|
69
|
+
return {
|
|
70
|
+
pages: count,
|
|
71
|
+
pageBytes: size,
|
|
72
|
+
bytes: new Uint8Array(count * size),
|
|
73
|
+
holder: new Array<string>(count).fill(''),
|
|
74
|
+
decoded: new Array<boolean>(count).fill(false),
|
|
75
|
+
slotOf: new Map<string, number>(),
|
|
76
|
+
touchedAt: new Map<string, number>(),
|
|
77
|
+
clock: 0,
|
|
78
|
+
frameStart: 0,
|
|
79
|
+
mode: options.mode ?? 'per-sample',
|
|
80
|
+
decode: options.decode ?? null,
|
|
81
|
+
evicted: [],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Say a new frame has begun, so everything acquired from here is protected from eviction.
|
|
87
|
+
*
|
|
88
|
+
* A caller that never calls this gets a cache that protects everything and fills up, which is a
|
|
89
|
+
* visible stall rather than a silent corruption — the right way round for a mistake to fail.
|
|
90
|
+
*/
|
|
91
|
+
export function beginCacheFrame(cache: PageCache): void {
|
|
92
|
+
cache.frameStart = cache.clock;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function touch(cache: PageCache, hash: string): void {
|
|
96
|
+
cache.clock += 1;
|
|
97
|
+
cache.touchedAt.set(hash, cache.clock);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The slot holding this tile's page, acquiring one if it is not held. `-1` where none can be given.
|
|
102
|
+
*
|
|
103
|
+
* Acquiring a hash that is already here returns the same slot and takes no page from anybody: the
|
|
104
|
+
* commonest call by far, and the one that must not evict.
|
|
105
|
+
*/
|
|
106
|
+
export function acquirePage(cache: PageCache, hash: string): number {
|
|
107
|
+
const held = cache.slotOf.get(hash);
|
|
108
|
+
if (held !== undefined) {
|
|
109
|
+
touch(cache, hash);
|
|
110
|
+
return held;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let slot = cache.holder.indexOf('');
|
|
114
|
+
if (slot < 0) {
|
|
115
|
+
const victim = evictable(cache);
|
|
116
|
+
if (victim === null) return -1;
|
|
117
|
+
slot = cache.slotOf.get(victim) as number;
|
|
118
|
+
release(cache, victim, slot);
|
|
119
|
+
cache.evicted.push(victim);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
cache.holder[slot] = hash;
|
|
123
|
+
cache.slotOf.set(hash, slot);
|
|
124
|
+
/* No `decoded[slot] = false` here: `release` owns that, and every slot reaching this point came
|
|
125
|
+
either from `release` or from never having been used. A second reset changed nothing, which a
|
|
126
|
+
perturbation showed by failing no test. */
|
|
127
|
+
cache.bytes.fill(0, slot * cache.pageBytes, (slot + 1) * cache.pageBytes);
|
|
128
|
+
touch(cache, hash);
|
|
129
|
+
|
|
130
|
+
/* The whole of the mode switch: in one, the bytes are produced now; in the other, at sample
|
|
131
|
+
time by whoever samples, and this cache holds the compressed page unchanged. */
|
|
132
|
+
if (cache.mode === 'on-residency') ensurePageDecoded(cache, hash);
|
|
133
|
+
return slot;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The least recently used page that this frame has not touched, or null. */
|
|
137
|
+
function evictable(cache: PageCache): string | null {
|
|
138
|
+
let worst: string | null = null;
|
|
139
|
+
let at = Infinity;
|
|
140
|
+
for (const hash of cache.slotOf.keys()) {
|
|
141
|
+
const when = cache.touchedAt.get(hash) ?? 0;
|
|
142
|
+
if (when > cache.frameStart) continue;
|
|
143
|
+
if (when >= at) continue;
|
|
144
|
+
at = when;
|
|
145
|
+
worst = hash;
|
|
146
|
+
}
|
|
147
|
+
return worst;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Take a page back, leaving the slot free and claimed by nobody.
|
|
152
|
+
*
|
|
153
|
+
* **`touchedAt` is deleted as well**, which is not tidiness: without it the map grows by one entry
|
|
154
|
+
* per tile the cache has ever seen, so a three-page cache that streamed a level holds three pages
|
|
155
|
+
* and thousands of timestamps. There is a test that counts them.
|
|
156
|
+
*/
|
|
157
|
+
function release(cache: PageCache, hash: string, slot: number): void {
|
|
158
|
+
cache.holder[slot] = '';
|
|
159
|
+
cache.decoded[slot] = false;
|
|
160
|
+
cache.slotOf.delete(hash);
|
|
161
|
+
cache.touchedAt.delete(hash);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Give a page back. Its slot is free for the next acquisition, and its bytes are not kept. */
|
|
165
|
+
export function releasePage(cache: PageCache, hash: string): void {
|
|
166
|
+
const slot = cache.slotOf.get(hash);
|
|
167
|
+
if (slot === undefined) return;
|
|
168
|
+
release(cache, hash, slot);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function pageSlot(cache: PageCache, hash: string): number {
|
|
172
|
+
return cache.slotOf.get(hash) ?? -1;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function pageDecoded(cache: PageCache, hash: string): boolean {
|
|
176
|
+
const slot = cache.slotOf.get(hash);
|
|
177
|
+
return slot === undefined ? false : (cache.decoded[slot] ?? false);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Decode a held page if the mode says the cache owns that, and it has not been done.
|
|
182
|
+
*
|
|
183
|
+
* Returns whether the page's bytes are now the decoded ones. `false` in `per-sample` mode is the
|
|
184
|
+
* ordinary answer rather than a failure: the bytes are the compressed page and whoever samples
|
|
185
|
+
* decodes them.
|
|
186
|
+
*/
|
|
187
|
+
export function ensurePageDecoded(cache: PageCache, hash: string): boolean {
|
|
188
|
+
if (cache.mode !== 'on-residency') return false;
|
|
189
|
+
const slot = cache.slotOf.get(hash);
|
|
190
|
+
if (slot === undefined) return false;
|
|
191
|
+
if (cache.decoded[slot] === true) return true;
|
|
192
|
+
if (cache.decode === null) return false;
|
|
193
|
+
cache.decode(hash, cache.bytes.subarray(slot * cache.pageBytes, (slot + 1) * cache.pageBytes));
|
|
194
|
+
cache.decoded[slot] = true;
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function decodeMode(cache: PageCache): DecodeMode {
|
|
199
|
+
return cache.mode;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Change where the decode happens, invalidating every page that was decoded under the old rule.
|
|
204
|
+
*
|
|
205
|
+
* **Invalidating rather than converting.** A page decoded per-sample and one decoded on residency
|
|
206
|
+
* hold different bytes, so keeping them and changing the flag serves a sampler the wrong ones —
|
|
207
|
+
* silently, and only for the pages that happened to be resident when somebody flipped the switch.
|
|
208
|
+
* The pages stay held; what is lost is the claim that their contents are current.
|
|
209
|
+
*/
|
|
210
|
+
export function setDecodeMode(cache: PageCache, mode: DecodeMode): void {
|
|
211
|
+
if (mode === cache.mode) return;
|
|
212
|
+
cache.mode = mode;
|
|
213
|
+
cache.decoded.fill(false);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** How many pages are held. What a profiler panel shows beside the resident tile count. */
|
|
217
|
+
export function occupiedPages(cache: PageCache): number {
|
|
218
|
+
return cache.slotOf.size;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Drain the hashes whose pages have been taken, into `out`. Returns how many.
|
|
223
|
+
*
|
|
224
|
+
* A caller that never drains this leaks a string per eviction, which is why it is a drain rather
|
|
225
|
+
* than a log: the list is a message to the residency table, and a message nobody reads is a bug in
|
|
226
|
+
* the caller that should grow rather than hide.
|
|
227
|
+
*/
|
|
228
|
+
export function takeEvicted(cache: PageCache, out: string[]): number {
|
|
229
|
+
out.length = 0;
|
|
230
|
+
for (const hash of cache.evicted) out.push(hash);
|
|
231
|
+
cache.evicted.length = 0;
|
|
232
|
+
return out.length;
|
|
233
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running the world forward to see what it will need, then putting it back.
|
|
3
|
+
*
|
|
4
|
+
* **This is the part no competitor can copy, and the reason is not the code.** The state of the art
|
|
5
|
+
* in texture streaming is reactive: the documentation for the most widely deployed implementation
|
|
6
|
+
* says so in as many words — streaming is *reactive by nature*, because the processor cannot know a
|
|
7
|
+
* tile is needed until after a frame has already needed it. Hence pop-in, and hence the further
|
|
8
|
+
* limitation that passes which write no feedback can sample tiles they are unable to request.
|
|
9
|
+
*
|
|
10
|
+
* That is true only of an engine that cannot run its simulation forward and put it back. This one
|
|
11
|
+
* can, and already does every frame for the netcode. So residency is decided by looking at where
|
|
12
|
+
* the camera *will* be:
|
|
13
|
+
*
|
|
14
|
+
* ```text
|
|
15
|
+
* every engine DriftEngine
|
|
16
|
+
* ──────────── ───────────
|
|
17
|
+
* render frame N save the simulation
|
|
18
|
+
* read the feedback buffer advance it N frames, deterministic, no rendering
|
|
19
|
+
* ↓ two to three frames late sample which tiles those views want
|
|
20
|
+
* request the tiles restore
|
|
21
|
+
* pop-in prefetch → resident before it is ever visible
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* **The simulation is taken as a handle the caller supplies**, in the save/restore shape
|
|
25
|
+
* `@driftengine/network`'s `Snapshotter` already uses, for two reasons: this package must not
|
|
26
|
+
* import a simulation, and a consumer with their own loop has to be able to supply their own.
|
|
27
|
+
*
|
|
28
|
+
* **A misprediction costs a wasted fetch and nothing else.** The tile arrives late, exactly as it
|
|
29
|
+
* would in a reactive engine — so the floor of this mechanism is everyone else's ceiling.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What prediction needs from a simulation.
|
|
34
|
+
*
|
|
35
|
+
* Save and restore rather than a snapshot value, matching `Snapshotter` in the network package:
|
|
36
|
+
* the buffer is the handle's own and is reused, so predicting allocates nothing.
|
|
37
|
+
*/
|
|
38
|
+
export interface SimulationHandle {
|
|
39
|
+
/** Capture the current state into the handle's own storage. */
|
|
40
|
+
save(): void;
|
|
41
|
+
/** Put back what `save` captured. */
|
|
42
|
+
restore(): void;
|
|
43
|
+
/** Advance one fixed step. Deterministic, and the same step the simulation really runs. */
|
|
44
|
+
advance(dt: number): void;
|
|
45
|
+
/** Write the current view matrix into `out`, sixteen floats. */
|
|
46
|
+
viewAt(out: Float32Array): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fill `out` with one view matrix per predicted frame, and leave the simulation exactly as found.
|
|
51
|
+
*
|
|
52
|
+
* Returns how many views were written — fewer than `frames` when `out` cannot hold them all.
|
|
53
|
+
*
|
|
54
|
+
* **Saved once and restored once**, however many frames are predicted: the intermediate states are
|
|
55
|
+
* not wanted, only the views they imply.
|
|
56
|
+
*/
|
|
57
|
+
export function predictViews(
|
|
58
|
+
sim: SimulationHandle,
|
|
59
|
+
frames: number,
|
|
60
|
+
dt: number,
|
|
61
|
+
out: Float32Array,
|
|
62
|
+
): number {
|
|
63
|
+
const capacity = Math.floor(out.length / 16);
|
|
64
|
+
const wanted = Math.min(frames, capacity);
|
|
65
|
+
if (wanted <= 0) return 0;
|
|
66
|
+
|
|
67
|
+
sim.save();
|
|
68
|
+
for (let i = 0; i < wanted; i += 1) {
|
|
69
|
+
sim.advance(dt);
|
|
70
|
+
sim.viewAt(out.subarray(i * 16, i * 16 + 16));
|
|
71
|
+
}
|
|
72
|
+
sim.restore();
|
|
73
|
+
return wanted;
|
|
74
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prediction, generic over what is being streamed.
|
|
3
|
+
*
|
|
4
|
+
* **Written generic now rather than generalised later, and the reason is a contract rather than
|
|
5
|
+
* tidiness.** Wave 4B streams world cells by exactly this mechanism. Writing the tile case against
|
|
6
|
+
* a concrete type would mean a second speculative-advance implementation there — and therefore a
|
|
7
|
+
* second place for "put the simulation back exactly" to be got wrong, in a subsystem where being
|
|
8
|
+
* wrong means the world runs at double speed only when streaming is enabled.
|
|
9
|
+
*
|
|
10
|
+
* One prediction, two consumers.
|
|
11
|
+
*/
|
|
12
|
+
import { predictViews } from './predict.ts';
|
|
13
|
+
import type { SimulationHandle } from './predict.ts';
|
|
14
|
+
|
|
15
|
+
export interface Predictor<T> {
|
|
16
|
+
/** What this view needs, most important first. Fills `out` and returns how many. */
|
|
17
|
+
needs(view: Float32Array, out: T[], budget: number): number;
|
|
18
|
+
/** Whether this item is already here. */
|
|
19
|
+
resident(item: T): boolean;
|
|
20
|
+
/** Ask for it. Lower priority is sooner. */
|
|
21
|
+
request(item: T, priority: number): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const VIEWS = new Float32Array(16 * 32);
|
|
25
|
+
const WANTED: unknown[] = [];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Advance the simulation, ask the predictor what each predicted view needs, and put it back.
|
|
29
|
+
*
|
|
30
|
+
* Returns how many requests were made. `budget` bounds the total across every predicted frame
|
|
31
|
+
* rather than per frame, so a near frame cannot be starved by a distant one filling the queue.
|
|
32
|
+
*/
|
|
33
|
+
export function runPrediction<T>(
|
|
34
|
+
sim: SimulationHandle,
|
|
35
|
+
predictor: Predictor<T>,
|
|
36
|
+
frames: number,
|
|
37
|
+
dt: number,
|
|
38
|
+
budget: number,
|
|
39
|
+
): number {
|
|
40
|
+
const horizon = Math.min(frames, Math.floor(VIEWS.length / 16));
|
|
41
|
+
const count = predictViews(sim, horizon, dt, VIEWS);
|
|
42
|
+
|
|
43
|
+
let requested = 0;
|
|
44
|
+
const out = WANTED as T[];
|
|
45
|
+
for (let frame = 0; frame < count && requested < budget; frame += 1) {
|
|
46
|
+
const view = VIEWS.subarray(frame * 16, frame * 16 + 16);
|
|
47
|
+
const wanted = predictor.needs(view, out, budget - requested);
|
|
48
|
+
for (let i = 0; i < wanted && requested < budget; i += 1) {
|
|
49
|
+
const item = out[i] as T;
|
|
50
|
+
if (predictor.resident(item)) continue;
|
|
51
|
+
/*
|
|
52
|
+
* Priority is how far ahead the frame is, so nearer frames come first — and within a frame,
|
|
53
|
+
* the place the predictor named the item in, as a fraction that never reaches the next
|
|
54
|
+
* frame. A frame's priority alone would tie its items, and a queue breaks a tie however it
|
|
55
|
+
* likes; a budget that cut the frame short would then keep an arbitrary part of it.
|
|
56
|
+
*/
|
|
57
|
+
predictor.request(item, frame + i / wanted);
|
|
58
|
+
requested += 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return requested;
|
|
62
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What to fetch next, ordered by how soon it will be needed.
|
|
3
|
+
*
|
|
4
|
+
* **That ordering is not available to a reactive system at all.** Everything a feedback buffer
|
|
5
|
+
* reports is already needed; there is nothing to rank. Prediction produces a horizon, so a tile
|
|
6
|
+
* wanted in one frame can outrank one wanted in eight — which is the difference between spending a
|
|
7
|
+
* limited byte budget well and spending it arbitrarily.
|
|
8
|
+
*
|
|
9
|
+
* **A full queue drops its worst entry rather than refusing the best one.** Refusing on arrival
|
|
10
|
+
* would discard exactly the tiles a late prediction found most urgent.
|
|
11
|
+
*/
|
|
12
|
+
export interface PrefetchQueue {
|
|
13
|
+
/** Priority per hash. Lower is sooner, so it sorts naturally. */
|
|
14
|
+
priority: Map<string, number>;
|
|
15
|
+
capacity: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createPrefetchQueue(capacity: number): PrefetchQueue {
|
|
19
|
+
return { priority: new Map(), capacity };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function enqueue(queue: PrefetchQueue, hash: string, priority: number): void {
|
|
23
|
+
const existing = queue.priority.get(hash);
|
|
24
|
+
/* One entry per tile, keeping the better claim on it. */
|
|
25
|
+
if (existing !== undefined) {
|
|
26
|
+
if (priority < existing) queue.priority.set(hash, priority);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
queue.priority.set(hash, priority);
|
|
30
|
+
if (queue.priority.size <= queue.capacity) return;
|
|
31
|
+
|
|
32
|
+
let worst = '';
|
|
33
|
+
let worstPriority = -Infinity;
|
|
34
|
+
for (const [key, value] of queue.priority) {
|
|
35
|
+
if (value > worstPriority) {
|
|
36
|
+
worstPriority = value;
|
|
37
|
+
worst = key;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
queue.priority.delete(worst);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function queueSize(queue: PrefetchQueue): number {
|
|
44
|
+
return queue.priority.size;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Take the next batch, soonest first, within a byte budget.
|
|
49
|
+
*
|
|
50
|
+
* **Residency is checked here rather than at enqueue**, because it may have changed in between —
|
|
51
|
+
* a tile requested eight frames ahead may well have arrived by the time its turn comes.
|
|
52
|
+
*/
|
|
53
|
+
export function takeBatch(
|
|
54
|
+
queue: PrefetchQueue,
|
|
55
|
+
isResident: (hash: string) => boolean,
|
|
56
|
+
bytesBudget: number,
|
|
57
|
+
tileBytes: number,
|
|
58
|
+
out: string[],
|
|
59
|
+
): number {
|
|
60
|
+
const ordered = [...queue.priority.entries()].sort(
|
|
61
|
+
(a, b) => a[1] - b[1] || (a[0] < b[0] ? -1 : 1),
|
|
62
|
+
);
|
|
63
|
+
let spent = 0;
|
|
64
|
+
let written = 0;
|
|
65
|
+
for (const [hash] of ordered) {
|
|
66
|
+
if (isResident(hash)) {
|
|
67
|
+
queue.priority.delete(hash);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (spent + tileBytes > bytesBudget) break;
|
|
71
|
+
out[written] = hash;
|
|
72
|
+
written += 1;
|
|
73
|
+
spent += tileBytes;
|
|
74
|
+
queue.priority.delete(hash);
|
|
75
|
+
}
|
|
76
|
+
out.length = written;
|
|
77
|
+
return written;
|
|
78
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Getting the bytes, and giving up on the ones that are not coming.
|
|
3
|
+
*
|
|
4
|
+
* **A tile that will not arrive stops being waited for.** That is the whole of this file's
|
|
5
|
+
* difficulty. A fetch that resolves with nothing — a tile the container does not hold, a name from
|
|
6
|
+
* a stale index, a range the server refused — leaves the tile marked requested and its slot
|
|
7
|
+
* occupied forever if nobody handles it. A handful of those and the in-flight budget is gone, so
|
|
8
|
+
* every subsequent fetch is refused and **streaming stops entirely**, with no error anywhere: the
|
|
9
|
+
* world simply never finishes loading, and the last thing that happened was unrelated.
|
|
10
|
+
*
|
|
11
|
+
* So a missing tile is remembered as missing. Not evicted-and-forgotten, which asks for it again on
|
|
12
|
+
* the next pump and turns one absent tile into a fetch every frame; and not left requested, which
|
|
13
|
+
* is the leak. `forgetMissing` exists because "missing" is a fact about a container and a caller
|
|
14
|
+
* that mounts a different one is entitled to ask again.
|
|
15
|
+
*
|
|
16
|
+
* **The source is the caller's.** What a tile's bytes are behind — a `.drft` container, a range
|
|
17
|
+
* request, a file — is not this package's business, and a streamer that knew would be a streamer
|
|
18
|
+
* every host had to agree with. It is one method returning a promise of bytes or nothing.
|
|
19
|
+
*
|
|
20
|
+
* **Nothing here awaits.** `pumpStreamer` starts work and returns; a source that never resolves
|
|
21
|
+
* costs an in-flight slot and nothing else. A streamer that awaited its own fetches would stall the
|
|
22
|
+
* frame that called it, which is the failure streaming exists to avoid.
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
TILE_ABSENT,
|
|
26
|
+
TILE_REQUESTED,
|
|
27
|
+
evict,
|
|
28
|
+
markRequested,
|
|
29
|
+
markResident,
|
|
30
|
+
tileState,
|
|
31
|
+
type ResidencyTable,
|
|
32
|
+
} from './table.ts';
|
|
33
|
+
import { acquirePage, takeEvicted, type PageCache } from './pageCache.ts';
|
|
34
|
+
|
|
35
|
+
/** Where a tile's bytes come from. Resolving `null` means the source does not have it. */
|
|
36
|
+
export interface TileSource {
|
|
37
|
+
fetch(hash: string): Promise<Uint8Array | null>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface StreamerOptions {
|
|
41
|
+
/**
|
|
42
|
+
* How many fetches may be outstanding at once.
|
|
43
|
+
*
|
|
44
|
+
* A bound rather than none, because an unbounded streamer asked for a thousand tiles opens a
|
|
45
|
+
* thousand connections and finishes none of them sooner.
|
|
46
|
+
*/
|
|
47
|
+
readonly maxInFlight?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface Streamer {
|
|
51
|
+
readonly source: TileSource;
|
|
52
|
+
readonly table: ResidencyTable;
|
|
53
|
+
readonly cache: PageCache;
|
|
54
|
+
readonly maxInFlight: number;
|
|
55
|
+
/** Tiles asked for and not yet answered. */
|
|
56
|
+
readonly inFlight: Set<string>;
|
|
57
|
+
/** Tiles the source answered `null` for. Not asked for again until forgotten. */
|
|
58
|
+
readonly missing: Set<string>;
|
|
59
|
+
/** Fetches that landed after their tile had been evicted. Counted, not silently dropped. */
|
|
60
|
+
stale: number;
|
|
61
|
+
/** Fetches that landed with no page free to put them in. The tile stays absent and is retried. */
|
|
62
|
+
deferred: number;
|
|
63
|
+
/** Fetches whose bytes were larger than a page. A container and a cache disagreeing. */
|
|
64
|
+
oversize: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createStreamer(
|
|
68
|
+
source: TileSource,
|
|
69
|
+
table: ResidencyTable,
|
|
70
|
+
cache: PageCache,
|
|
71
|
+
options: StreamerOptions = {},
|
|
72
|
+
): Streamer {
|
|
73
|
+
return {
|
|
74
|
+
source,
|
|
75
|
+
table,
|
|
76
|
+
cache,
|
|
77
|
+
maxInFlight: Math.max(1, Math.floor(options.maxInFlight ?? 8)),
|
|
78
|
+
inFlight: new Set<string>(),
|
|
79
|
+
missing: new Set<string>(),
|
|
80
|
+
stale: 0,
|
|
81
|
+
deferred: 0,
|
|
82
|
+
oversize: 0,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function streamerInFlight(streamer: Streamer): number {
|
|
87
|
+
return streamer.inFlight.size;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Stop calling a tile missing, so the next pump asks for it again. */
|
|
91
|
+
export function forgetMissing(streamer: Streamer, hash: string): void {
|
|
92
|
+
streamer.missing.delete(hash);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function clearMissing(streamer: Streamer): void {
|
|
96
|
+
streamer.missing.clear();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Ask for the first `count` tiles of `batch` that are worth asking for. Returns how many were sent.
|
|
101
|
+
*
|
|
102
|
+
* Skips anything already resident, already in flight, or known missing — which is what makes
|
|
103
|
+
* calling this every frame with the same batch correct rather than a fetch storm.
|
|
104
|
+
*/
|
|
105
|
+
export function pumpStreamer(streamer: Streamer, batch: readonly string[], count: number): number {
|
|
106
|
+
let sent = 0;
|
|
107
|
+
/* A bound on the loop rather than a correctness check — the `undefined` guard below is what
|
|
108
|
+
makes a short batch behave, and a perturbation removing this one fails no test. It stays
|
|
109
|
+
because without it `pumpStreamer(s, [], 1e9)` spins a billion times to do nothing. */
|
|
110
|
+
const wanted = Math.min(count, batch.length);
|
|
111
|
+
for (let at = 0; at < wanted; at += 1) {
|
|
112
|
+
if (streamer.inFlight.size >= streamer.maxInFlight) break;
|
|
113
|
+
const hash = batch[at];
|
|
114
|
+
if (hash === undefined) continue;
|
|
115
|
+
if (streamer.missing.has(hash)) continue;
|
|
116
|
+
/* Already asked for. Usually the table says so too, but not after an eviction while the fetch
|
|
117
|
+
was still out: the tile is absent again and a second fetch of bytes already on their way is
|
|
118
|
+
exactly what a content-addressed store makes pointless. */
|
|
119
|
+
if (streamer.inFlight.has(hash)) continue;
|
|
120
|
+
if (tileState(streamer.table, hash) !== TILE_ABSENT) continue;
|
|
121
|
+
|
|
122
|
+
markRequested(streamer.table, hash);
|
|
123
|
+
streamer.inFlight.add(hash);
|
|
124
|
+
sent += 1;
|
|
125
|
+
/*
|
|
126
|
+
* Not awaited: this returns to the frame that called it. A rejection is treated exactly as a
|
|
127
|
+
* missing tile, because from here they are the same fact — the bytes are not coming — and a
|
|
128
|
+
* streamer that let one through would be the leak this file is about, arriving by the one path
|
|
129
|
+
* nobody writes a test for.
|
|
130
|
+
*/
|
|
131
|
+
void streamer.source
|
|
132
|
+
.fetch(hash)
|
|
133
|
+
.then((bytes) => {
|
|
134
|
+
land(streamer, hash, bytes);
|
|
135
|
+
})
|
|
136
|
+
.catch(() => {
|
|
137
|
+
land(streamer, hash, null);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return sent;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function land(streamer: Streamer, hash: string, bytes: Uint8Array | null): void {
|
|
144
|
+
streamer.inFlight.delete(hash);
|
|
145
|
+
|
|
146
|
+
/*
|
|
147
|
+
* Evicted while in flight. Its slot belongs to somebody else now, so these bytes go nowhere —
|
|
148
|
+
* and the tile is left absent rather than resurrected, because whatever evicted it decided it
|
|
149
|
+
* was not wanted and this arriving late does not change that.
|
|
150
|
+
*/
|
|
151
|
+
if (tileState(streamer.table, hash) !== TILE_REQUESTED) {
|
|
152
|
+
streamer.stale += 1;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (bytes === null) {
|
|
157
|
+
evict(streamer.table, hash);
|
|
158
|
+
streamer.missing.add(hash);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (bytes.length > streamer.cache.pageBytes) {
|
|
163
|
+
/* A truncation here would be a tile that decodes to something nobody authored, found weeks
|
|
164
|
+
later as a corrupt-looking texture. Refused, counted, and left absent. */
|
|
165
|
+
streamer.oversize += 1;
|
|
166
|
+
evict(streamer.table, hash);
|
|
167
|
+
streamer.missing.add(hash);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const slot = acquirePage(streamer.cache, hash);
|
|
172
|
+
if (slot < 0) {
|
|
173
|
+
/* Every page is in use by this frame. The tile stays absent, so the next pump asks again. */
|
|
174
|
+
streamer.deferred += 1;
|
|
175
|
+
evict(streamer.table, hash);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
drainEvictions(streamer);
|
|
179
|
+
|
|
180
|
+
streamer.cache.bytes.set(bytes, slot * streamer.cache.pageBytes);
|
|
181
|
+
markResident(streamer.table, hash, slot);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Tell the table about pages the cache took, so nothing is called resident in a slot it lost.
|
|
186
|
+
*
|
|
187
|
+
* The one piece of wiring between the two structures, and the reason `takeEvicted` exists: a table
|
|
188
|
+
* still pointing at a reused slot hands a frame another tile's bytes.
|
|
189
|
+
*/
|
|
190
|
+
function drainEvictions(streamer: Streamer): void {
|
|
191
|
+
const taken: string[] = [];
|
|
192
|
+
takeEvicted(streamer.cache, taken);
|
|
193
|
+
for (const hash of taken) evict(streamer.table, hash);
|
|
194
|
+
}
|