@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.
- package/LICENSE +202 -0
- package/LICENSES +696 -0
- package/README.md +33 -2
- package/components/PmRunPanel.css +167 -0
- package/components/PmRunPanel.tsx +389 -0
- package/components/PmStepRun.css +61 -0
- package/components/PmStepRun.tsx +202 -0
- package/package.json +50 -3
- package/src/client/RunOutputs.css +21 -0
- package/src/client/RunOutputs.tsx +69 -0
- package/src/client/StepMark.css +3 -0
- package/src/client/StepMark.tsx +12 -0
- package/src/client/env.d.ts +13 -0
- package/src/client/feedback.ts +69 -0
- package/src/client/format.ts +30 -0
- package/src/client/index.ts +14 -0
- package/src/client/messages.ts +102 -0
- package/src/client/outputs.ts +29 -0
- package/src/client/plugin.ts +55 -0
- package/src/client/pyodide/executor.ts +94 -0
- package/src/client/pyodide/presmium_gpu.py +297 -0
- package/src/client/pyodide/protocol.ts +27 -0
- package/src/client/pyodide/pyodide.worker.ts +68 -0
- package/src/client/pyodide/run_await.py +57 -0
- package/src/client/runs-in.ts +27 -0
- package/src/client/script/executor.ts +62 -0
- package/src/client/script/script.worker.ts +9 -0
- package/src/client/slide-fit.ts +61 -0
- package/src/client/step-marks.ts +7 -0
- package/src/client/when-service.ts +14 -0
- package/src/domain/browser-python.ts +33 -0
- package/src/domain/config.ts +41 -0
- package/src/domain/index.ts +11 -0
- package/src/domain/outputs.ts +18 -0
- package/src/domain/router.ts +50 -0
- package/src/domain/runs.ts +72 -0
- package/src/domain/static-output.ts +20 -0
- package/src/domain/trace.ts +32 -0
- package/src/node/browser-imports.ts +34 -0
- package/src/node/child-process.ts +109 -0
- package/src/node/code-component.ts +48 -0
- package/src/node/notebook-cells.ts +46 -0
- package/src/node/presmium.ts +27 -0
- package/src/node/pyodide.ts +22 -0
- package/src/node/python.ts +40 -0
- package/src/node/run-panel.ts +71 -0
- package/src/node/script-main.mjs +11 -0
- package/src/node/script.ts +26 -0
- package/src/node/step-run.ts +71 -0
- package/src/node/timeouts.ts +15 -0
- package/src/shared/script-runner.d.mts +16 -0
- 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
|
+
}
|