@lincyaw/addon-exec 0.0.0-stage → 1.0.0-rc.2

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.
Files changed (52) hide show
  1. package/LICENSE +202 -0
  2. package/LICENSES +696 -0
  3. package/README.md +33 -2
  4. package/components/PmRunPanel.css +167 -0
  5. package/components/PmRunPanel.tsx +389 -0
  6. package/components/PmStepRun.css +61 -0
  7. package/components/PmStepRun.tsx +202 -0
  8. package/package.json +50 -3
  9. package/src/client/RunOutputs.css +21 -0
  10. package/src/client/RunOutputs.tsx +69 -0
  11. package/src/client/StepMark.css +3 -0
  12. package/src/client/StepMark.tsx +12 -0
  13. package/src/client/env.d.ts +13 -0
  14. package/src/client/feedback.ts +69 -0
  15. package/src/client/format.ts +30 -0
  16. package/src/client/index.ts +14 -0
  17. package/src/client/messages.ts +102 -0
  18. package/src/client/outputs.ts +29 -0
  19. package/src/client/plugin.ts +55 -0
  20. package/src/client/pyodide/executor.ts +94 -0
  21. package/src/client/pyodide/presmium_gpu.py +297 -0
  22. package/src/client/pyodide/protocol.ts +27 -0
  23. package/src/client/pyodide/pyodide.worker.ts +68 -0
  24. package/src/client/pyodide/run_await.py +57 -0
  25. package/src/client/runs-in.ts +27 -0
  26. package/src/client/script/executor.ts +62 -0
  27. package/src/client/script/script.worker.ts +9 -0
  28. package/src/client/slide-fit.ts +61 -0
  29. package/src/client/step-marks.ts +7 -0
  30. package/src/client/when-service.ts +14 -0
  31. package/src/domain/browser-python.ts +33 -0
  32. package/src/domain/config.ts +41 -0
  33. package/src/domain/index.ts +11 -0
  34. package/src/domain/outputs.ts +18 -0
  35. package/src/domain/router.ts +50 -0
  36. package/src/domain/runs.ts +72 -0
  37. package/src/domain/static-output.ts +20 -0
  38. package/src/domain/trace.ts +32 -0
  39. package/src/node/browser-imports.ts +34 -0
  40. package/src/node/child-process.ts +109 -0
  41. package/src/node/code-component.ts +48 -0
  42. package/src/node/notebook-cells.ts +46 -0
  43. package/src/node/presmium.ts +27 -0
  44. package/src/node/pyodide.ts +22 -0
  45. package/src/node/python.ts +40 -0
  46. package/src/node/run-panel.ts +71 -0
  47. package/src/node/script-main.mjs +11 -0
  48. package/src/node/script.ts +26 -0
  49. package/src/node/step-run.ts +71 -0
  50. package/src/node/timeouts.ts +15 -0
  51. package/src/shared/script-runner.d.mts +16 -0
  52. package/src/shared/script-runner.mjs +168 -0
@@ -0,0 +1,55 @@
1
+ import { siteBase, STATUS } from '@lincyaw/host'
2
+ import { EXECUTOR } from '@lincyaw/kernel'
3
+ import type { PluginDefinition } from '@lincyaw/kernel'
4
+ import { version } from '../../package.json'
5
+ import { createExecutorRouter, EXEC_ID, execConfig, TRACER } from '../domain/index.ts'
6
+ import { messages } from './messages.ts'
7
+ import { withRuntimeFeedback } from './feedback.ts'
8
+ import { createPyodideExecutor } from './pyodide/executor.ts'
9
+ import type { PyodideExecutor } from './pyodide/executor.ts'
10
+ import { declaredEnvironments } from './runs-in.ts'
11
+ import { createScriptExecutor } from './script/executor.ts'
12
+ import type { ScriptExecutor } from './script/executor.ts'
13
+
14
+ let pyodide: PyodideExecutor | undefined
15
+ let script: ScriptExecutor | undefined
16
+ let stopStatus: (() => void) | undefined
17
+
18
+ export const execPlugin: PluginDefinition = {
19
+ manifest: { id: EXEC_ID, name: { 'zh-CN': '代码运行', en: 'Code Runner' }, version, kernel: '^1.0.0', provides: [EXECUTOR, TRACER], requires: [{ token: STATUS, optional: true }], capabilities: ['exec'] },
20
+ setup(ctx) {
21
+ const config = execConfig(ctx.config)
22
+ declaredEnvironments.set(ctx.environments ?? {})
23
+ pyodide?.dispose()
24
+ script?.dispose()
25
+ pyodide = createPyodideExecutor({
26
+ indexURL: config.pyodideURL ?? `${siteBase()}pyodide/`,
27
+ timeoutMs: config.timeoutMs,
28
+ loadTimeoutMs: config.loadTimeoutMs,
29
+ maxTraceEvents: config.maxTraceEvents,
30
+ })
31
+ script = createScriptExecutor({ timeoutMs: config.timeoutMs })
32
+ const router = createExecutorRouter({
33
+ noExecutor: lang => messages().noExecutor(lang),
34
+ executorUnavailable: (id, lang) => messages().executorUnavailable(id, lang),
35
+ executorCrashed: (id, error) => messages().executorCrashed(id, error),
36
+ })
37
+ router.register(pyodide)
38
+ router.register(script)
39
+ const feedback = withRuntimeFeedback(router)
40
+ stopStatus?.()
41
+ stopStatus = ctx.tryGet(STATUS)?.register({ id: EXEC_ID, read: feedback.read })
42
+ ctx.provide(EXECUTOR, feedback.executor)
43
+ ctx.provide(TRACER, pyodide)
44
+ },
45
+ dispose() {
46
+ pyodide?.dispose()
47
+ pyodide = undefined
48
+ script?.dispose()
49
+ script = undefined
50
+ stopStatus?.()
51
+ stopStatus = undefined
52
+ },
53
+ }
54
+
55
+ export default execPlugin
@@ -0,0 +1,94 @@
1
+ import { createResidentWorker } from '@lincyaw/host'
2
+ import { failedRun } from '@lincyaw/kernel'
3
+ import type { Executor, RunRequest, RunResult } from '@lincyaw/kernel'
4
+ import { isPython } from '../../domain/index.ts'
5
+ import type { Trace, Tracer } from '../../domain/index.ts'
6
+ import { messages } from '../messages.ts'
7
+ import type { WorkerReply, WorkerRequest, WorkerRunResult, WorkerTraceResult } from './protocol.ts'
8
+
9
+ export const PYODIDE_EXECUTOR_ID = 'pyodide'
10
+
11
+ export interface PyodideExecutorOptions {
12
+ indexURL: string
13
+ timeoutMs: number
14
+ loadTimeoutMs: number
15
+ maxTraceEvents: number
16
+ }
17
+
18
+ export type PyodideExecutor = Executor & Tracer & {
19
+ dispose(): void
20
+ }
21
+
22
+ type Outcome<T> =
23
+ | { ok: true; value: T; durationMs: number }
24
+ | { ok: false; error: string; timedOut: boolean; durationMs: number }
25
+
26
+ export function createPyodideExecutor(options: PyodideExecutorOptions): PyodideExecutor {
27
+ const id = PYODIDE_EXECUTOR_ID
28
+ const traces = new Map<string, Promise<Trace>>()
29
+ const resident = createResidentWorker<WorkerRequest, WorkerReply>({
30
+ start: () => new Worker(new URL('./pyodide.worker.ts', import.meta.url), { type: 'module', name: 'presmium-pyodide' }),
31
+ loadTimeoutMs: options.loadTimeoutMs,
32
+ })
33
+
34
+ async function send<T>(kind: WorkerRequest['kind'], request: RunRequest, signal?: AbortSignal): Promise<Outcome<T>> {
35
+ const timeoutMs = request.timeoutMs ?? options.timeoutMs
36
+ const indexURL = new URL(options.indexURL, location.href).href
37
+ const outcome = await resident.send({ kind, request: plain(request), indexURL, maxEvents: options.maxTraceEvents }, { timeoutMs, signal })
38
+ if ('reply' in outcome) {
39
+ const { reply } = outcome
40
+ return reply.type === 'done'
41
+ ? { ok: true, value: reply.value as T, durationMs: reply.durationMs }
42
+ : { ok: false, error: reply.error, timedOut: false, durationMs: outcome.elapsedMs }
43
+ }
44
+ const t = messages()
45
+ const error = {
46
+ 'cancelled': t.cancelled,
47
+ 'load-timeout': t.loadTimeout('Python', options.loadTimeoutMs),
48
+ 'run-timeout': t.runTimeout(timeoutMs),
49
+ 'crashed': t.runtimeCrashed('Python', outcome.error ?? ''),
50
+ }[outcome.failure]
51
+ return { ok: false, error, timedOut: outcome.failure === 'load-timeout' || outcome.failure === 'run-timeout', durationMs: outcome.elapsedMs }
52
+ }
53
+
54
+ return {
55
+ id,
56
+ runsIn: 'browser',
57
+ supports: isPython,
58
+ async run(request, signal): Promise<RunResult> {
59
+ const outcome = await send<WorkerRunResult>('run', request, signal)
60
+ if (!outcome.ok) return { ...failedRun(id, outcome.error, outcome.durationMs), timedOut: outcome.timedOut }
61
+ const { stdout, stderr, exitCode, outputs } = outcome.value
62
+ const unavailable = outputs?.find(output => output.output_type === 'error' && output.ename === 'GPUUnavailable')
63
+ if (unavailable?.output_type === 'error') return { ...failedRun(id, messages().gpuUnavailable(unavailable.evalue), outcome.durationMs), stdout }
64
+ return { ok: exitCode === 0, stdout, stderr, exitCode, durationMs: outcome.durationMs, timedOut: false, source: 'live', executorId: id, ...outputs ? { outputs } : {} }
65
+ },
66
+ async endSession(session) {
67
+ if (resident.hasWorker()) await send<null>('end', { lang: 'python', code: '', session })
68
+ },
69
+ dispose() {
70
+ resident.dispose()
71
+ traces.clear()
72
+ },
73
+ trace(request, signal) {
74
+ const key = JSON.stringify([request.code, request.args, request.stdin, request.files])
75
+ let trace = traces.get(key)
76
+ if (!trace) {
77
+ trace = send<WorkerTraceResult>('trace', request, signal).then(toTrace, (error: unknown) => ({ events: [], truncated: false, error: String(error) }))
78
+ traces.set(key, trace)
79
+ void trace.then(t => t.error !== undefined && traces.delete(key))
80
+ }
81
+ return trace
82
+ },
83
+ }
84
+ }
85
+
86
+ function toTrace(outcome: Outcome<WorkerTraceResult>): Trace {
87
+ if (!outcome.ok) return { events: [], truncated: false, error: outcome.error }
88
+ const { events, truncated, output } = outcome.value
89
+ return events.length ? { events, truncated } : { events, truncated, error: output || undefined }
90
+ }
91
+
92
+ function plain(request: RunRequest): RunRequest {
93
+ return JSON.parse(JSON.stringify(request)) as RunRequest
94
+ }
@@ -0,0 +1,297 @@
1
+ """
2
+ WebGPU from Python in the browser. The Pyodide worker of the execution plugin makes this
3
+ module importable: it runs a WGSL compute shader with `navigator.gpu`, through Pyodide's bridge
4
+ to JavaScript, as the WebGPU plugin runs `{run-panel} wgsl`: buffers by the names of the shader's
5
+ variables (a list of numbers, or a dict with `data`, `size`, `fill`, `type`, `read`), `dispatch`,
6
+ `entry`, `repeat` and `features`.
7
+
8
+ import presmium_gpu as gpu
9
+ result = await gpu.run(SHADER, buffers={'a': [1, 2], 'b': [3, 4], 'out': {'size': 2}})
10
+ result['out'] # [4.0, 6.0], the buffers read back (those the shader may write)
11
+ print(result) # each buffer, the median time of a dispatch and the adapter
12
+
13
+ `await` works at the top of a code block. Where the browser has JavaScript Promise Integration
14
+ (JSPI), `gpu.run_sync(...)` does the same without `await` (`gpu.can_run_sync()` says whether it
15
+ can). A shader that cannot run here (no WebGPU, no adapter, a missing feature or limit, a lost
16
+ device) raises `GPUUnavailable`; one WebGPU rejects, `GPUError`. The run has the scale limits of
17
+ `{run-panel} wgsl` (the WebGPU plugin's `LIMITS`): all buffers together at most 128 MiB, and
18
+ `repeat` at most 1000; buffers, `dispatch` or `repeat` beyond them or of the wrong shape raise
19
+ `ValueError` before the GPU is asked for.
20
+ """
21
+ import re
22
+
23
+ import js
24
+ import pyodide.ffi
25
+ from pyodide.ffi import JsException, can_run_sync, create_once_callable, to_js
26
+ from pyodide.ffi import run_sync as _run_sync
27
+
28
+ __all__ = ['GPUError', 'GPUUnavailable', 'Result', 'can_run_sync', 'run', 'run_sync']
29
+
30
+ _ARRAYS = {'f32': 'Float32Array', 'i32': 'Int32Array', 'u32': 'Uint32Array'}
31
+ # Values shown at each end of a long buffer.
32
+ _ENDS = 4
33
+ # The scale limits of a run, as the WebGPU plugin's `LIMITS` (src/domain/config.ts there).
34
+ _MAX_BYTES = 128 * 1024 * 1024
35
+ _MAX_REPEAT = 1000
36
+ _DECLARATION = re.compile(r'((?:@\s*\w+\s*(?:\([^)]*\))?\s*)+)var\s*<\s*(storage|uniform)\s*(?:,\s*(read_write|read))?\s*>\s*([A-Za-z_]\w*)\s*:\s*([^;]+);')
37
+ _SHORTHAND = {'f': 'f32', 'i': 'i32', 'u': 'u32', 'h': 'f16'}
38
+
39
+
40
+ class GPUUnavailable(RuntimeError):
41
+ """The shader cannot run in this browser: no WebGPU, no adapter, a missing feature or limit, a lost device."""
42
+
43
+
44
+ class GPUError(RuntimeError):
45
+ """WebGPU rejected the shader or the buffers."""
46
+
47
+
48
+ def _js(value):
49
+ return to_js(value, dict_converter=js.Object.fromEntries)
50
+
51
+
52
+ def _absent(value):
53
+ """JavaScript's `null` or `undefined` (Pyodide gives `null` as `jsnull`)."""
54
+ return value is None or value is getattr(pyodide.ffi, 'jsnull', None)
55
+
56
+
57
+ def _declarations(code):
58
+ """The buffers the shader declares: name, group, binding, address space, access, scalar type."""
59
+ source = re.sub(r'//.*$', '', re.sub(r'/\*[\s\S]*?\*/', '', code), flags=re.M)
60
+ found = []
61
+ for attributes, space, access, name, kind in _DECLARATION.findall(source):
62
+ def attribute(key):
63
+ match = re.search(r'@\s*%s\s*\(\s*(\d+)\s*\)' % key, attributes)
64
+ return int(match.group(1)) if match else 0
65
+ scalars = set(re.findall(r'\b(f32|i32|u32|f16)\b', kind)) | {_SHORTHAND[s] for s in re.findall(r'\b(?:vec[234]|mat[234]x[234])([fiuh])\b', kind)}
66
+ found.append({
67
+ 'name': name,
68
+ 'group': attribute('group'),
69
+ 'binding': attribute('binding'),
70
+ 'usage': space,
71
+ 'access': 'read_write' if space == 'storage' and access == 'read_write' else 'read',
72
+ 'scalar': next(iter(scalars)) if len(scalars) == 1 else None,
73
+ })
74
+ return found
75
+
76
+
77
+ def _buffers(code, specs):
78
+ declared = _declarations(code)
79
+ for name in specs:
80
+ if not any(d['name'] == name for d in declared):
81
+ raise ValueError('The shader has no buffer named %s (var<storage> or var<uniform>)' % name)
82
+ buffers = []
83
+ for d in declared:
84
+ if d['name'] not in specs:
85
+ raise ValueError("The shader's buffer %s is not in buffers" % d['name'])
86
+ spec = specs[d['name']]
87
+ if not isinstance(spec, (list, tuple, range, dict)):
88
+ raise ValueError('The buffer %s is a list of numbers, or {data, size, fill, type, read}' % d['name'])
89
+ fields = {'data': list(spec)} if isinstance(spec, (list, tuple, range)) else dict(spec)
90
+ data = fields.get('data')
91
+ size = fields.get('size')
92
+ if (data is not None and not (isinstance(data, (list, tuple, range)) and all(_is_number(v) for v in data))) or (size is not None and not _positive(size)):
93
+ raise ValueError('The buffer %s is a list of numbers, or {data, size, fill, type, read}' % d['name'])
94
+ kind = fields.get('type') or d['scalar'] or 'f32'
95
+ if kind not in _ARRAYS:
96
+ raise ValueError('The type %s of the buffer %s is not supported (f32, i32, u32)' % (kind, d['name']))
97
+ length = max(size or 0, len(data) if data is not None else 0)
98
+ if length == 0:
99
+ raise ValueError('The buffer %s needs data or size (its number of values)' % d['name'])
100
+ read = fields.get('read')
101
+ buffers.append(dict(d, type=kind, length=length, data=data, fill=fields.get('fill'), read=d['access'] == 'read_write' if read is None else read))
102
+ return buffers
103
+
104
+
105
+ def _is_number(value):
106
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and value == value and abs(value) != float('inf')
107
+
108
+
109
+ def _positive(value, most=float('inf')):
110
+ return isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= most
111
+
112
+
113
+ def _bytes(buffer):
114
+ size = max(4, buffer['length'] * 4)
115
+ return -(-size // 16) * 16 if buffer['usage'] == 'uniform' else size
116
+
117
+
118
+ def _number(value):
119
+ """A value as the summary shows it: integers whole, others to 6 significant digits."""
120
+ if value == value and abs(value) != float('inf') and value == int(value) and abs(value) < 1e15:
121
+ return str(int(value))
122
+ return '%.6g' % value
123
+
124
+
125
+ class Result(dict):
126
+ """The buffers read back, by name (lists of numbers), with `timing` and `adapter`."""
127
+
128
+ def __init__(self, values, types, timing, adapter):
129
+ super().__init__(values)
130
+ self.types = types
131
+ self.timing = timing
132
+ self.adapter = adapter
133
+
134
+ def __str__(self):
135
+ lines = []
136
+ for name, values in self.items():
137
+ shown = values if len(values) <= _ENDS * 2 else values[:_ENDS] + ['…'] + values[-_ENDS:]
138
+ line = '%s: %s[%d] = [%s]' % (name, self.types[name], len(values), ', '.join(v if v == '…' else _number(v) for v in shown))
139
+ if len(values) > _ENDS * 2:
140
+ line += ' min %s, max %s, mean %s' % (_number(min(values)), _number(max(values)), _number(sum(values) / len(values)))
141
+ lines.append(line)
142
+ timing, adapter = self.timing, self.adapter
143
+ notes = [n for n in ['median of %d' % timing['samples'] if timing['samples'] > 1 else '', 'GPU timestamps' if timing['source'] == 'gpu' else 'wall clock', 'CPU emulation' if adapter['fallback'] else ''] if n]
144
+ lines.append('time: %s ms per dispatch (%s)' % (_number(float('%.3g' % timing['ms'])), '; '.join(notes)))
145
+ name = ' '.join(p for p in [adapter['vendor'], adapter['architecture']] if p) or adapter['description'] or 'unknown'
146
+ lines.append('adapter: %s%s' % (name, ' (fallback adapter)' if adapter['fallback'] else ''))
147
+ return '\n'.join(lines)
148
+
149
+
150
+ def _adapter(adapter):
151
+ info = getattr(adapter, 'info', None)
152
+
153
+ def field(key, default=''):
154
+ return default if _absent(info) else getattr(info, key, default)
155
+
156
+ fallback = bool(field('isFallbackAdapter', False) or getattr(adapter, 'isFallbackAdapter', False)) or field('architecture') == 'swiftshader'
157
+ return {'vendor': field('vendor'), 'architecture': field('architecture'), 'description': field('description'), 'fallback': fallback}
158
+
159
+
160
+ async def run(shader, buffers, dispatch=1, entry=None, repeat=1, features=()):
161
+ """Runs the compute shader `repeat` times on the buffers; returns the `Result`."""
162
+ specs = _buffers(shader, buffers)
163
+ groups = list(dispatch) if isinstance(dispatch, (list, tuple)) else [dispatch]
164
+ if not 1 <= len(groups) <= 3 or not all(_positive(n) for n in groups):
165
+ raise ValueError('dispatch is 1 to 3 positive integers (workgroups in each dimension)')
166
+ groups += [1] * (3 - len(groups))
167
+ if not _positive(repeat, _MAX_REPEAT):
168
+ raise ValueError('repeat is an integer from 1 to %d' % _MAX_REPEAT)
169
+ total = sum(_bytes(b) for b in specs)
170
+ if total > _MAX_BYTES:
171
+ raise ValueError('The buffers take %d bytes, over the scale limit of %d bytes' % (total, _MAX_BYTES))
172
+ gpu = getattr(js.navigator, 'gpu', None)
173
+ if _absent(gpu):
174
+ raise GPUUnavailable('This browser has no WebGPU')
175
+ adapter = await gpu.requestAdapter()
176
+ if _absent(adapter):
177
+ raise GPUUnavailable('No GPU adapter is available')
178
+ described = _adapter(adapter)
179
+ missing = [f for f in features if not adapter.features.has(f)]
180
+ if missing:
181
+ raise GPUUnavailable('The GPU adapter lacks the feature %s' % ', '.join(missing))
182
+ storage = [_bytes(b) for b in specs if b['usage'] == 'storage']
183
+ uniform = [_bytes(b) for b in specs if b['usage'] == 'uniform']
184
+ needs = {
185
+ 'maxBufferSize': max([4] + [_bytes(b) for b in specs]),
186
+ 'maxStorageBufferBindingSize': max([4] + storage),
187
+ 'maxUniformBufferBindingSize': max([4] + uniform),
188
+ 'maxStorageBuffersPerShaderStage': len(storage),
189
+ 'maxUniformBuffersPerShaderStage': len(uniform),
190
+ }
191
+ for limit, needed in needs.items():
192
+ if needed > getattr(adapter.limits, limit):
193
+ raise GPUUnavailable("The GPU adapter's limits are too low: %s %d < %d" % (limit, getattr(adapter.limits, limit), needed))
194
+ timestamps = adapter.features.has('timestamp-query')
195
+ try:
196
+ device = await adapter.requestDevice(_js({'requiredFeatures': list(features) + (['timestamp-query'] if timestamps else []), 'requiredLimits': needs}))
197
+ except JsException as error:
198
+ raise GPUUnavailable("The GPU adapter's limits are too low: %s" % error) from None
199
+ lost = []
200
+ device.lost.then(create_once_callable(lambda info: lost.append(info.message) if info.reason != 'destroyed' else None))
201
+ try:
202
+ return await _dispatch(device, shader, specs, groups, entry, repeat, timestamps, described)
203
+ except JsException as error:
204
+ if lost:
205
+ raise GPUUnavailable('The GPU device was lost: %s' % lost[0]) from None
206
+ raise GPUError(str(error)) from None
207
+ finally:
208
+ device.destroy()
209
+
210
+
211
+ async def _dispatch(device, shader, specs, groups, entry, repeat, timestamps, adapter):
212
+ usage = js.GPUBufferUsage
213
+ device.pushErrorScope('validation')
214
+ module = device.createShaderModule(_js({'code': shader}))
215
+ errors = [m for m in (await module.getCompilationInfo()).messages if m.type == 'error']
216
+ if errors:
217
+ await device.popErrorScope()
218
+ raise GPUError('The shader has errors:\n' + '\n'.join('%d:%d %s' % (m.lineNum, m.linePos, m.message) for m in errors))
219
+ count = max([b['group'] + 1 for b in specs] + [0])
220
+ layouts = [device.createBindGroupLayout(_js({'entries': [
221
+ {'binding': b['binding'], 'visibility': js.GPUShaderStage.COMPUTE, 'buffer': {'type': 'uniform' if b['usage'] == 'uniform' else 'read-only-storage' if b['access'] == 'read' else 'storage'}}
222
+ for b in specs if b['group'] == group
223
+ ]})) for group in range(count)]
224
+ stage = {'module': module, 'entryPoint': entry} if entry else {'module': module}
225
+ pipeline = device.createComputePipeline(_js({'layout': device.createPipelineLayout(_js({'bindGroupLayouts': layouts})), 'compute': stage}))
226
+ gpu_buffers = []
227
+ for b in specs:
228
+ buffer = device.createBuffer(_js({'size': _bytes(b), 'usage': (usage.UNIFORM if b['usage'] == 'uniform' else usage.STORAGE) | usage.COPY_SRC | usage.COPY_DST, 'mappedAtCreation': True}))
229
+ values = getattr(js, _ARRAYS[b['type']]).new(buffer.getMappedRange())
230
+ if b['data'] is not None:
231
+ values.set(_js(list(b['data'])))
232
+ elif b['fill']:
233
+ values.fill(b['fill'])
234
+ buffer.unmap()
235
+ gpu_buffers.append(buffer)
236
+ bind_groups = [device.createBindGroup(_js({'layout': layout, 'entries': [
237
+ {'binding': b['binding'], 'resource': {'buffer': gpu_buffers[i]}} for i, b in enumerate(specs) if b['group'] == group
238
+ ]})) for group, layout in enumerate(layouts)]
239
+
240
+ def encode(encoder, writes=None):
241
+ compute_pass = encoder.beginComputePass(_js({'timestampWrites': writes} if writes else {}))
242
+ compute_pass.setPipeline(pipeline)
243
+ for i, group in enumerate(bind_groups):
244
+ compute_pass.setBindGroup(i, group)
245
+ compute_pass.dispatchWorkgroups(*groups)
246
+ compute_pass.end()
247
+
248
+ samples = []
249
+ times = None
250
+ if timestamps:
251
+ queries = repeat * 2
252
+ query_set = device.createQuerySet(_js({'type': 'timestamp', 'count': queries}))
253
+ resolved = device.createBuffer(_js({'size': queries * 8, 'usage': usage.QUERY_RESOLVE | usage.COPY_SRC}))
254
+ times = device.createBuffer(_js({'size': queries * 8, 'usage': usage.MAP_READ | usage.COPY_DST}))
255
+ encoder = device.createCommandEncoder()
256
+ for i in range(repeat):
257
+ encode(encoder, {'querySet': query_set, 'beginningOfPassWriteIndex': i * 2, 'endOfPassWriteIndex': i * 2 + 1})
258
+ encoder.resolveQuerySet(query_set, 0, queries, resolved, 0)
259
+ encoder.copyBufferToBuffer(resolved, 0, times, 0, queries * 8)
260
+ device.queue.submit(_js([encoder.finish()]))
261
+ else:
262
+ for _ in range(repeat):
263
+ encoder = device.createCommandEncoder()
264
+ encode(encoder)
265
+ started = js.performance.now()
266
+ device.queue.submit(_js([encoder.finish()]))
267
+ await device.queue.onSubmittedWorkDone()
268
+ samples.append(js.performance.now() - started)
269
+ shown = [(b, device.createBuffer(_js({'size': _bytes(b), 'usage': usage.MAP_READ | usage.COPY_DST})), gpu_buffers[i]) for i, b in enumerate(specs) if b['read']]
270
+ encoder = device.createCommandEncoder()
271
+ for b, copy, buffer in shown:
272
+ encoder.copyBufferToBuffer(buffer, 0, copy, 0, _bytes(b))
273
+ device.queue.submit(_js([encoder.finish()]))
274
+ error = await device.popErrorScope()
275
+ if not _absent(error):
276
+ raise GPUError('WebGPU reported an error: %s' % error.message)
277
+
278
+ if times is not None:
279
+ await times.mapAsync(js.GPUMapMode.READ)
280
+ ns = js.BigUint64Array.new(times.getMappedRange()).to_py().tolist()
281
+ samples = [(ns[i * 2 + 1] - ns[i * 2]) / 1e6 for i in range(repeat)]
282
+ values = {}
283
+ for b, copy, _buffer in shown:
284
+ await copy.mapAsync(js.GPUMapMode.READ)
285
+ values[b['name']] = getattr(js, _ARRAYS[b['type']]).new(copy.getMappedRange(), 0, b['length']).to_py().tolist()
286
+ ordered = sorted(samples)
287
+ middle = len(ordered) // 2
288
+ ms = ordered[middle] if len(ordered) % 2 else (ordered[middle - 1] + ordered[middle]) / 2
289
+ timing = {'ms': ms, 'samples': len(samples), 'source': 'gpu' if timestamps else 'wall'}
290
+ return Result(values, {b['name']: b['type'] for b, _copy, _buffer in shown}, timing, adapter)
291
+
292
+
293
+ def run_sync(shader, buffers, **options):
294
+ """`run` without `await`, where the browser has JavaScript Promise Integration (JSPI)."""
295
+ if not can_run_sync():
296
+ raise RuntimeError('run_sync needs JavaScript Promise Integration (JSPI), which this browser lacks; use `await presmium_gpu.run(...)`')
297
+ return _run_sync(run(shader, buffers, **options))
@@ -0,0 +1,27 @@
1
+ import type { RunOutput, RunRequest } from '@lincyaw/kernel'
2
+
3
+ export interface WorkerRequest {
4
+ id: number
5
+ kind: 'run' | 'trace' | 'end'
6
+ request: RunRequest
7
+ indexURL: string
8
+ maxEvents: number
9
+ }
10
+
11
+ export type WorkerReply =
12
+ | { id: number; type: 'started' }
13
+ | { id: number; type: 'done'; value: WorkerRunResult | WorkerTraceResult | null; durationMs: number }
14
+ | { id: number; type: 'failed'; error: string }
15
+
16
+ export interface WorkerRunResult {
17
+ stdout: string
18
+ stderr: string
19
+ exitCode: number
20
+ outputs?: RunOutput[]
21
+ }
22
+
23
+ export interface WorkerTraceResult {
24
+ events: { line: number; output: string }[]
25
+ truncated: boolean
26
+ output: string
27
+ }
@@ -0,0 +1,68 @@
1
+ /// <reference lib="webworker" />
2
+ import { errorMessage } from '@lincyaw/kernel'
3
+ import type { loadPyodide as LoadPyodide, PyodideInterface } from 'pyodide'
4
+ import runner from '@lincyaw/kernel/runner.py?raw'
5
+ import gpuBridge from './presmium_gpu.py?raw'
6
+ import type { WorkerReply, WorkerRequest } from './protocol.ts'
7
+ import runAwait from './run_await.py?raw'
8
+
9
+ declare const self: DedicatedWorkerGlobalScope
10
+
11
+ const MODULES = '/presmium'
12
+ const JSPI = typeof (WebAssembly as { Suspending?: unknown }).Suspending === 'function'
13
+
14
+ let runtime: Promise<PyodideInterface> | undefined
15
+
16
+ async function start(indexURL: string): Promise<PyodideInterface> {
17
+ const { loadPyodide } = (await import(/* @vite-ignore */ `${indexURL}pyodide.mjs`)) as { loadPyodide: typeof LoadPyodide }
18
+ const pyodide = await loadPyodide({ indexURL })
19
+ pyodide.runPython(runner)
20
+ pyodide.runPython(runAwait)
21
+ pyodide.FS.mkdirTree(MODULES)
22
+ pyodide.FS.writeFile(`${MODULES}/presmium_gpu.py`, gpuBridge)
23
+ pyodide.runPython(`import sys\nsys.path.append(${JSON.stringify(MODULES)})`)
24
+ return pyodide
25
+ }
26
+
27
+ function load(indexURL: string): Promise<PyodideInterface> {
28
+ runtime ??= start(indexURL).catch((error: unknown) => {
29
+ runtime = undefined
30
+ throw error
31
+ })
32
+ return runtime
33
+ }
34
+
35
+ const reply = (message: WorkerReply) => self.postMessage(message)
36
+
37
+ function awaiting(pyodide: PyodideInterface, code: string): boolean {
38
+ const awaits = pyodide.globals.get('_pm_awaits')
39
+ try {
40
+ return awaits(code) as boolean
41
+ } finally {
42
+ awaits.destroy()
43
+ }
44
+ }
45
+
46
+ self.onmessage = async ({ data }: MessageEvent<WorkerRequest>) => {
47
+ const { id, kind, request, indexURL, maxEvents } = data
48
+ try {
49
+ const pyodide = await load(indexURL)
50
+ reply({ id, type: 'started' })
51
+ const awaits = kind === 'run' && awaiting(pyodide, request.code)
52
+ const fn = pyodide.globals.get(kind === 'trace' ? '_pm_trace' : kind === 'end' ? '_pm_end' : awaits ? '_pm_run_await' : '_pm_run')
53
+ try {
54
+ const started = performance.now()
55
+ const args = [request.code, JSON.stringify(request.args ?? {}), request.stdin ?? '', JSON.stringify(request.files ?? {}), maxEvents, request.session ?? null]
56
+ const json = kind === 'end'
57
+ ? (fn(request.session ?? ''), 'null')
58
+ : kind === 'run' && (awaits || JSPI)
59
+ ? await (awaits ? fn(...args) : fn.callPromising(...args)) as string
60
+ : fn(...args) as string
61
+ reply({ id, type: 'done', value: JSON.parse(json), durationMs: Math.round(performance.now() - started) })
62
+ } finally {
63
+ fn.destroy()
64
+ }
65
+ } catch (error) {
66
+ reply({ id, type: 'failed', error: errorMessage(error) })
67
+ }
68
+ }
@@ -0,0 +1,57 @@
1
+ # Top-level `await` for Python in the browser: the asynchronous form of the WebGPU bridge
2
+ # (`await presmium_gpu.run(...)`) and other awaitables Pyodide gives (`pyodide.http`).
3
+ # Loaded into the Pyodide worker after the shared runner (@lincyaw/kernel/runner.py), whose
4
+ # helpers it uses: `_pm_run_await` runs code as `_pm_run` does, with the same outputs (the value of a
5
+ # last expression too), session and isolation, but compiled to allow `await` at its top level, and
6
+ # the worker awaits it.
7
+ import ast as _pm_ast
8
+ import inspect as _pm_inspect
9
+
10
+ _PM_AWAIT = _pm_ast.PyCF_ALLOW_TOP_LEVEL_AWAIT
11
+
12
+
13
+ def _pm_awaits(code):
14
+ """Whether the code awaits at its top level: it compiles only when that is allowed."""
15
+ try:
16
+ compile(code, _PM_FILE, 'exec')
17
+ return False
18
+ except SyntaxError:
19
+ pass
20
+ try:
21
+ return bool(compile(code, _PM_FILE, 'exec', flags=_PM_AWAIT).co_flags & _pm_inspect.CO_COROUTINE)
22
+ except SyntaxError:
23
+ return False
24
+
25
+
26
+ async def _pm_evaluate(code, scope):
27
+ """Runs compiled code; awaits it when it awaits at its top level."""
28
+ value = eval(code, scope)
29
+ return await value if code.co_flags & _pm_inspect.CO_COROUTINE else value
30
+
31
+
32
+ async def _pm_run_await(code, args_json, stdin, files_json, _max_events, session_name=None):
33
+ global _PM_CURRENT
34
+ outputs = _PmOutputs()
35
+ exit_code = 0
36
+ session = _pm_session(session_name) if session_name else None
37
+ scope = session['scope'] if session is not None else {}
38
+ _PM_CURRENT = outputs
39
+ try:
40
+ with _PmIsolation(None, session), contextlib.redirect_stdout(_PmStream(outputs, 'stdout')), contextlib.redirect_stderr(_PmStream(outputs, 'stderr')):
41
+ try:
42
+ # The arguments, stdin and files as the runner gives them; then the code, with await.
43
+ _pm_prepare('', args_json, stdin, files_json, scope)
44
+ linecache.cache[_PM_FILE] = (len(code), None, code.splitlines(True), _PM_FILE)
45
+ program, last = _pm_split(code, _PM_AWAIT)
46
+ await _pm_evaluate(program, scope)
47
+ if last is not None:
48
+ _pm_result(outputs, await _pm_evaluate(last, scope))
49
+ _pm_figures()
50
+ except SystemExit as e:
51
+ exit_code = e.code if isinstance(e.code, int) else (0 if e.code is None else 1)
52
+ except BaseException:
53
+ outputs.error(*_pm_user_traceback())
54
+ exit_code = 1
55
+ finally:
56
+ _PM_CURRENT = None
57
+ return json.dumps({'stdout': ''.join(outputs.text['stdout']), 'stderr': ''.join(outputs.text['stderr']), 'exitCode': exit_code, 'outputs': outputs.items})
@@ -0,0 +1,27 @@
1
+ import { environmentExecutor } from '@lincyaw/kernel'
2
+ import type { EnvironmentSpec, Executor } from '@lincyaw/kernel'
3
+ import { createStore } from '@lincyaw/host'
4
+
5
+ export type RunsIn =
6
+ | { kind: 'browser' }
7
+ | { kind: 'machine' }
8
+ | { kind: 'remote', name: string }
9
+ | { kind: 'environment', name: string }
10
+
11
+ /** The environments of the deck's headmatter, which the plugin's setup records. */
12
+ export const declaredEnvironments = createStore<Readonly<Record<string, EnvironmentSpec>>>({})
13
+
14
+ /** The environment kinds of the other execution plugins, whose named executors do not say where they run. */
15
+ const KINDS: Record<string, RunsIn['kind']> = { wasi: 'browser', webgpu: 'browser', devcontainer: 'machine', jupyter: 'remote' }
16
+
17
+ /** Where the executor runs code: its own `runsIn`, or for a named environment the kind the headmatter declares. */
18
+ export function runsInOf(executorId: string | undefined, executor?: Executor): RunsIn | undefined {
19
+ if (!executorId) return undefined
20
+ const prefix = environmentExecutor('')
21
+ if (executorId.startsWith(prefix)) {
22
+ const name = executorId.slice(prefix.length)
23
+ const kind = executor?.runsIn ?? KINDS[declaredEnvironments.getSnapshot()[name]?.kind ?? ''] ?? 'environment'
24
+ return kind === 'browser' || kind === 'machine' ? { kind } : { kind, name }
25
+ }
26
+ return executor?.runsIn ? { kind: executor.runsIn } : undefined
27
+ }