fxbex 0.2.1__py3-none-any.whl

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.
fxbex/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Independent FXBEX package builder and installed-adapter runtime."""
2
+ __version__ = "0.2.1"
3
+
4
+ from .llm import LLM
5
+ __all__ = ['LLM']
fxbex/__main__.py ADDED
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
fxbex/cli.py ADDED
@@ -0,0 +1,124 @@
1
+ """CLI for local FXB_EX packages; SDK is imported only for runtime operations."""
2
+ import argparse
3
+ import json
4
+ from pathlib import Path
5
+ import sys
6
+ from .package import build, inspect
7
+ from .validation import validate
8
+
9
+
10
+ def _main(argv=None):
11
+ parser = argparse.ArgumentParser(
12
+ prog='fxbex', description='Build, inspect and run compiled FXB_EX model packages.',
13
+ formatter_class=argparse.RawDescriptionHelpFormatter,
14
+ epilog="""Examples:
15
+ fxbex build ./compiled_model -o model.fxbex
16
+ fxbex inspect model.fxbex
17
+ fxbex validate model.fxbex --integrity-only
18
+ fxbex validate model.fxbex --weights-dir ./weights
19
+ fxbex run model.fxbex --input inputs.json -o result.json
20
+ fxbex chat model.fxbex
21
+ fxbex serve model.fxbex --port 8000
22
+ fxbex doctor
23
+
24
+ Validation checks static readiness; it does not run inference or prove NPU execution.
25
+ Model-specific exporters and capture commands belong to their adapter plugins.""")
26
+ sub = parser.add_subparsers(dest='action', required=True)
27
+ p = sub.add_parser('build', help='Pack a compiled directory with checksums (no compilation)')
28
+ p.add_argument('source', help='Compiled directory containing manifest and required JSON configurations')
29
+ p.add_argument('-o', '--output', required=True, help='New .fxbex file; existing files are never overwritten')
30
+ p.add_argument('--standalone', action='store_true', help='Copy referenced external weights into the archive')
31
+ p = sub.add_parser('inspect', help='Verify integrity and display the manifest; no SDK needed')
32
+ p.add_argument('package', help='Path to a .fxbex archive')
33
+ p = sub.add_parser('validate', help='Check integrity, SDK, adapter, weights and device availability without inference')
34
+ p.add_argument('package', help='Path to a .fxbex archive')
35
+ p.add_argument('--integrity-only', action='store_true', help='Verify archive only; skip SDK, weights and device checks')
36
+ p.add_argument('--weights-dir', help='Override the external checkpoint/weights directory')
37
+ sub.add_parser('doctor', help='Report installed SDK versions and RNGD device status')
38
+ p = sub.add_parser('ui', help='Launch the local package interface')
39
+ p.add_argument('--port', type=int, default=7861, help='HTTP port (default: 7861)')
40
+ for name, help_text in (
41
+ ('run', 'Run JSON inputs or start interactive chat for a chat adapter'),
42
+ ('chat', 'Chat interactively, or process a messages JSON file'),
43
+ ('predict', 'Run a tensor DAG with named JSON inputs'),
44
+ ('serve', 'Serve the loaded package over local HTTP'),
45
+ ):
46
+ p = sub.add_parser(name, help=help_text, description=help_text)
47
+ p.add_argument('package', help='Path to a .fxbex archive')
48
+ p.add_argument('--report-dir', help='Directory for runtime logs and evidence')
49
+ p.add_argument('--weights-dir', help='Override external checkpoint/weights directory')
50
+ if name != 'serve':
51
+ p.add_argument('--input', help='JSON file: named tensors for predict, or {"messages": [...]} for chat')
52
+ p.add_argument('-o', '--output', help='Write response JSON to this file')
53
+ p.add_argument('--max-new-tokens', type=int, default=32, help='Chat token limit (default: 32)')
54
+ else:
55
+ p.add_argument('--port', type=int, default=8000, help='HTTP port (default: 8000)')
56
+ args=parser.parse_args(argv)
57
+ if args.action=='build':result={'package':str(build(args.source,args.output,standalone=args.standalone))}
58
+ elif args.action == 'inspect':
59
+ m=inspect(args.package)
60
+ result={'model':m['model'],'runtime':m['runtime'],'compatibility':m['compatibility'],
61
+ 'standalone':m['standalone'],'files':len(m['files']),
62
+ 'artifact_bytes':sum(x['size'] for x in m['files'].values()),'integrity_verified':True}
63
+ elif args.action == 'validate':
64
+ result = validate(args.package, integrity_only=args.integrity_only, weights_dir=args.weights_dir)
65
+ elif args.action=='doctor':
66
+ from .runtime import doctor
67
+ result=doctor()
68
+ elif args.action=='ui':
69
+ from .ui import launch
70
+ launch(args.port);return
71
+ else:
72
+ from .runtime import Session
73
+ # SDK/compiler diagnostics may print; keep JSON results on stdout.
74
+ import contextlib
75
+ if args.action == 'predict' and not args.input:
76
+ raise ValueError('--input JSON file is required for tensor prediction')
77
+ session=Session(args.package,report_dir=args.report_dir,weights_dir=args.weights_dir)
78
+ log_path=session.report_dir/'runtime.log'
79
+ print(f'Loading package; diagnostics: {log_path}',file=sys.stderr,flush=True)
80
+ with log_path.open('w') as log, contextlib.redirect_stdout(log), contextlib.redirect_stderr(log):
81
+ session.__enter__()
82
+ print(f'NPU package ready; reports: {session.report_dir}',file=sys.stderr,flush=True)
83
+ try:
84
+ if args.action=='serve':
85
+ from .server import serve
86
+ serve(session,args.port);return
87
+ if args.action=='predict' or (args.action=='run' and session.manifest['runtime']['adapter']=='runnable_dag'):
88
+ if not args.input:raise ValueError('--input JSON file is required for tensor prediction')
89
+ result=session.predict(json.loads(Path(args.input).read_text()))
90
+ elif args.input:
91
+ data=json.loads(Path(args.input).read_text())
92
+ result=session.chat(data['messages'],data.get('max_new_tokens',args.max_new_tokens))
93
+ else:
94
+ messages=[]
95
+ print('FXB_EX chat: /reset clears history, /quit unloads.',flush=True)
96
+ while True:
97
+ try:prompt=input('you> ')
98
+ except EOFError:break
99
+ if prompt.strip()=='/quit':break
100
+ if prompt.strip()=='/reset':messages=[];continue
101
+ if not prompt.strip():continue
102
+ candidate=messages+[{'role':'user','content':prompt}]
103
+ result=session.chat(candidate,args.max_new_tokens)
104
+ messages=candidate+[{'role':'assistant','content':result['text']}]
105
+ print(result['text'],flush=True)
106
+ return
107
+ finally:session.__exit__(None,None,None)
108
+ if args.output:Path(args.output).write_text(json.dumps(result,ensure_ascii=False,indent=2))
109
+ result['report_dir']=str(session.report_dir)
110
+ print(json.dumps(result,ensure_ascii=False,indent=2))
111
+ return 0 if result.get('valid', True) else 1
112
+
113
+ def main(argv=None):
114
+ try:
115
+ return _main(argv) or 0
116
+ except Exception as exc:
117
+ print(f'fxbex: {exc}', file=sys.stderr)
118
+ return 1
119
+ except KeyboardInterrupt:
120
+ return 130
121
+
122
+
123
+ if __name__ == '__main__':
124
+ sys.exit(main())
fxbex/llm.py ADDED
@@ -0,0 +1,52 @@
1
+ """Small process-isolated Python interface for validated chat packages."""
2
+ import threading
3
+ from .package import inspect
4
+ from .service import PackageService
5
+
6
+
7
+ class LLM:
8
+ """Load an FXBEX chat package once. Close explicitly or use a with block.
9
+
10
+ fxb is an alias for fxbex; neither converts an official Furiosa FXB.
11
+ Model identity must match the package. No implicit downloads or compilation.
12
+ """
13
+ def __init__(self, model=None, *, fxbex=None, fxb=None):
14
+ if (fxbex is None)==(fxb is None):
15
+ raise ValueError('Specify exactly one of fxbex= or fxb= with an FXBEX package')
16
+ path=fxbex if fxbex is not None else fxb
17
+ # Validate content rather than trusting the filename extension.
18
+ try:manifest=inspect(path)
19
+ except (ValueError, OSError) as exc:
20
+ raise ValueError('Expected an FXBEX package; official .fxb requires furiosa_llm.LLM') from exc
21
+ if model is not None and model!=manifest['model']['name']:
22
+ raise ValueError(f"Model mismatch: requested {model!r}, package contains {manifest['model']['name']!r}")
23
+ self.model=manifest['model']['name'];self.history=[]
24
+ self._lock=threading.RLock();self._service=PackageService(path)
25
+ self._service.load()
26
+
27
+ def chat(self, messages, max_new_tokens=32):
28
+ """Return a result dict (text, metrics); caller supplies explicit history."""
29
+ with self._lock:return self._service.chat(messages,max_new_tokens)
30
+
31
+ def generate(self, prompt, max_new_tokens=32):
32
+ """Single-turn generation, returning the same result dict as chat."""
33
+ return self.chat([{'role':'user','content':prompt}],max_new_tokens)
34
+
35
+ def converse(self, prompt, max_new_tokens=32):
36
+ """Keep successful turns until reset(); failed calls do not mutate history."""
37
+ with self._lock:
38
+ messages=self.history+[{'role':'user','content':prompt}]
39
+ result=self.chat(messages,max_new_tokens)
40
+ self.history=messages+[{'role':'assistant','content':result['text']}]
41
+ return result
42
+
43
+ def reset(self):
44
+ with self._lock:self.history=[]
45
+
46
+ def close(self):
47
+ with self._lock:self._service.unload()
48
+
49
+ unload=close
50
+
51
+ def __enter__(self):return self
52
+ def __exit__(self,*args):self.close()
fxbex/package.py ADDED
@@ -0,0 +1,258 @@
1
+ """Model-independent FXB_EX archives; this is not Furiosa's official FXB format."""
2
+ from contextlib import contextmanager
3
+ import hashlib
4
+ import io
5
+ import json
6
+ import os
7
+ from pathlib import Path, PurePosixPath
8
+ import stat
9
+ import tempfile
10
+ import zipfile
11
+
12
+ REQUIRED = ('execution_plan.json', 'weights_index.json', 'memory_plan.json', 'runtime_config.json')
13
+ DEFAULT_MAX_TOTAL_SIZE = 64 * 1024**3
14
+ DEFAULT_MAX_JSON_SIZE = 16 * 1024**2
15
+ CHUNK = 1024**2
16
+
17
+
18
+ class PackageError(ValueError):
19
+ """An invalid or unsafe FXB_EX package."""
20
+
21
+
22
+ def _name(name):
23
+ if (not isinstance(name, str) or not name or '\\' in name or '\x00' in name
24
+ or ':' in name or name.startswith('/')
25
+ or any(p in ('', '.', '..') for p in name.split('/'))):
26
+ raise PackageError(f'Unsafe member path: {name!r}')
27
+ return name
28
+
29
+
30
+ def _json(data, label):
31
+ def pairs(items):
32
+ result = {}
33
+ for key, value in items:
34
+ if key in result:
35
+ raise PackageError(f'Duplicate JSON key in {label}: {key}')
36
+ result[key] = value
37
+ return result
38
+ try:
39
+ value = json.loads(data, object_pairs_hook=pairs)
40
+ except (ValueError, UnicodeError) as exc:
41
+ raise PackageError(f'Invalid JSON in {label}: {exc}') from exc
42
+ if not isinstance(value, dict):
43
+ raise PackageError(f'{label} must be a JSON object')
44
+ return value
45
+
46
+
47
+ def _manifest(manifest):
48
+ if type(manifest.get('schema_version')) is not int or manifest['schema_version'] != 1 or manifest.get('format') != 'FXB_EX':
49
+ raise PackageError('Expected FXB_EX schema_version 1')
50
+ if (not isinstance(manifest.get('model'), dict)
51
+ or not isinstance(manifest['model'].get('name'), str) or not manifest['model']['name']):
52
+ raise PackageError('manifest.model.name is required')
53
+ if not isinstance(manifest.get('compatibility'), dict):
54
+ raise PackageError('manifest.compatibility must be an object')
55
+ runtime = manifest.get('runtime')
56
+ if not isinstance(runtime, dict) or not isinstance(runtime.get('adapter'), str) or not runtime['adapter']:
57
+ raise PackageError('manifest.runtime.adapter is required')
58
+ if not isinstance(manifest.get('standalone'), bool):
59
+ raise PackageError('manifest.standalone must be boolean')
60
+ files = manifest.get('files')
61
+ if not isinstance(files, dict) or not set(REQUIRED).issubset(files):
62
+ raise PackageError('Integrity map must include every required configuration')
63
+ for name, item in files.items():
64
+ _name(name)
65
+ if name == 'manifest.json' or not isinstance(item, dict):
66
+ raise PackageError(f'Invalid integrity entry: {name}')
67
+ digest = item.get('sha256')
68
+ if (not isinstance(digest, str) or len(digest) != 64
69
+ or any(c not in '0123456789abcdef' for c in digest)
70
+ or type(item.get('size')) is not int or item['size'] < 0):
71
+ raise PackageError(f'Invalid checksum or size: {name}')
72
+
73
+
74
+ def _weights(index, manifest):
75
+ weights = index.get('weights')
76
+ if not isinstance(weights, list):
77
+ raise PackageError('weights_index.weights must be a list')
78
+ for weight in weights:
79
+ if not isinstance(weight, dict) or ('path' in weight) == ('external_path' in weight):
80
+ raise PackageError('Each weight needs exactly one path or external_path')
81
+ if 'path' in weight:
82
+ if _name(weight['path']) not in manifest['files']:
83
+ raise PackageError(f'Missing packaged weight: {weight["path"]}')
84
+ elif manifest['standalone']:
85
+ raise PackageError('Standalone packages cannot reference external weights')
86
+ elif not isinstance(weight['external_path'], str) or not weight['external_path']:
87
+ raise PackageError('external_path must be a nonempty string')
88
+
89
+
90
+ def _validate(archive, max_total_size, max_json_size):
91
+ if max_total_size <= 0 or max_json_size <= 0:
92
+ raise ValueError('Size limits must be positive')
93
+ members = {}
94
+ total = 0
95
+ for info in archive.infolist():
96
+ name = _name(info.filename)
97
+ mode = info.external_attr >> 16
98
+ if name in members or info.is_dir() or stat.S_ISLNK(mode) or (stat.S_IFMT(mode) not in (0, stat.S_IFREG)):
99
+ raise PackageError(f'Duplicate or unsupported member: {name}')
100
+ if info.flag_bits & 1:
101
+ raise PackageError('Encrypted members are unsupported')
102
+ total += info.file_size
103
+ if total > max_total_size:
104
+ raise PackageError('Package exceeds total uncompressed size limit')
105
+ members[name] = info
106
+ for name in members:
107
+ if any(str(parent) in members for parent in PurePosixPath(name).parents if str(parent) != '.'):
108
+ raise PackageError(f'Member conflicts with parent file: {name}')
109
+ for name in ('manifest.json', *REQUIRED):
110
+ if name not in members:
111
+ raise PackageError(f'Missing required member: {name}')
112
+ if members[name].file_size > max_json_size:
113
+ raise PackageError(f'JSON exceeds size limit: {name}')
114
+ manifest = _json(archive.read('manifest.json'), 'manifest.json')
115
+ _manifest(manifest)
116
+ if set(members) != {'manifest.json', *manifest['files']}:
117
+ raise PackageError('Archive members do not match manifest integrity map')
118
+ for name, entry in manifest['files'].items():
119
+ if members[name].file_size != entry['size']:
120
+ raise PackageError(f'Size mismatch: {name}')
121
+ digest = hashlib.sha256()
122
+ with archive.open(name) as stream:
123
+ while chunk := stream.read(CHUNK):
124
+ digest.update(chunk)
125
+ if digest.hexdigest() != entry['sha256']:
126
+ raise PackageError(f'Checksum mismatch: {name}')
127
+ configs = {name: _json(archive.read(name), name) for name in REQUIRED}
128
+ _weights(configs['weights_index.json'], manifest)
129
+ return manifest
130
+
131
+
132
+ def validate(path, *, max_total_size=DEFAULT_MAX_TOTAL_SIZE, max_json_size=DEFAULT_MAX_JSON_SIZE):
133
+ """Verify member safety, bounded sizes, required JSONs and all SHA-256 checksums.
134
+
135
+ External weight existence and runtime/NPU compatibility are not checked.
136
+ """
137
+ try:
138
+ with zipfile.ZipFile(path) as archive:
139
+ return _validate(archive, max_total_size, max_json_size)
140
+ except (zipfile.BadZipFile, RuntimeError, NotImplementedError) as exc:
141
+ raise PackageError(f'Unreadable archive: {exc}') from exc
142
+
143
+
144
+ def inspect(path, **limits):
145
+ """Return the manifest only after full archive validation."""
146
+ return validate(path, **limits)
147
+
148
+
149
+ @contextmanager
150
+ def extract(path, *, max_total_size=DEFAULT_MAX_TOTAL_SIZE, max_json_size=DEFAULT_MAX_JSON_SIZE):
151
+ """Yield a validated temporary package directory, deleting it on exit.
152
+
153
+ One open archive is used for validation and extraction to avoid path replacement.
154
+ """
155
+ try:
156
+ with zipfile.ZipFile(path) as archive:
157
+ _validate(archive, max_total_size, max_json_size)
158
+ with tempfile.TemporaryDirectory(prefix='fxbex-') as directory:
159
+ root = Path(directory)
160
+ for info in archive.infolist():
161
+ target = root.joinpath(*PurePosixPath(info.filename).parts)
162
+ target.parent.mkdir(parents=True, exist_ok=True)
163
+ with archive.open(info) as source, target.open('xb') as dest:
164
+ while chunk := source.read(CHUNK):
165
+ dest.write(chunk)
166
+ yield root
167
+ except (zipfile.BadZipFile, RuntimeError, NotImplementedError) as exc:
168
+ raise PackageError(f'Unreadable archive: {exc}') from exc
169
+
170
+
171
+ def build(source, output, standalone=False, *, max_total_size=DEFAULT_MAX_TOTAL_SIZE, max_json_size=DEFAULT_MAX_JSON_SIZE):
172
+ """Build atomically without overwriting; source is an unpacked package tree.
173
+
174
+ Source manifest supplies model, compatibility and runtime. Integrity entries and
175
+ standalone are generated. weights_index uses {"weights": [{"path": "weights/a"}]}
176
+ for internal weights or {"external_path": "/deployment/a"} for external weights.
177
+ In standalone mode external files are streamed into weights/ and their index
178
+ entries are rewritten in the archive only. Relative external paths resolve
179
+ against source; explicit external symlinks are resolved (e.g. HF cache blobs).
180
+ """
181
+ source = Path(source).resolve()
182
+ output = Path(output).absolute()
183
+ if not source.is_dir():
184
+ raise PackageError('Source must be a directory')
185
+ if output.resolve().is_relative_to(source):
186
+ raise PackageError('Output must be outside the source directory')
187
+ if output.exists() or output.is_symlink():
188
+ raise FileExistsError(output)
189
+ manifest_path = source / 'manifest.json'
190
+ if manifest_path.is_symlink() or not manifest_path.is_file():
191
+ raise PackageError('Source requires a regular manifest.json')
192
+ if manifest_path.stat().st_size > max_json_size:
193
+ raise PackageError('Manifest exceeds JSON size limit')
194
+ manifest = _json(manifest_path.read_bytes(), 'manifest.json')
195
+ manifest.update(schema_version=1, format='FXB_EX', standalone=bool(standalone), files={})
196
+ inputs = {}
197
+ for path in sorted(source.rglob('*')):
198
+ if path.is_symlink():
199
+ raise PackageError(f'Source symlinks are forbidden: {path}')
200
+ if path.is_dir():
201
+ continue
202
+ if not path.is_file():
203
+ raise PackageError(f'Source must contain regular files: {path}')
204
+ name = _name(path.relative_to(source).as_posix())
205
+ if name != 'manifest.json':
206
+ inputs[name] = (path, None)
207
+ if standalone:
208
+ index_path = source / 'weights_index.json'
209
+ if not index_path.is_file() or index_path.stat().st_size > max_json_size:
210
+ raise PackageError('Missing or oversized weights_index.json')
211
+ index = _json(index_path.read_bytes(), 'weights_index.json')
212
+ # Validate the original references before replacing external entries.
213
+ _weights(index, {'standalone': False, 'files': inputs})
214
+ for weight in index['weights']:
215
+ if 'external_path' not in weight:
216
+ continue
217
+ external = Path(weight['external_path'])
218
+ basename = weight.get('name', external.name)
219
+ _name(basename)
220
+ if '/' in basename:
221
+ raise PackageError(f'Weight name must be a basename: {basename}')
222
+ name = 'weights/' + basename
223
+ if name in inputs:
224
+ raise PackageError(f'Weight path collision: {name}')
225
+ external = (external if external.is_absolute() else source / external).resolve()
226
+ if not external.is_file():
227
+ raise PackageError(f'Missing external weight file: {external}')
228
+ inputs[name] = (external, weight.get('sha256'))
229
+ del weight['external_path']
230
+ weight['path'] = name
231
+ inputs['weights_index.json'] = (json.dumps(index, sort_keys=True).encode(), None)
232
+ output.parent.mkdir(parents=True, exist_ok=True)
233
+ fd, temporary = tempfile.mkstemp(prefix='.fxbex-', dir=output.parent)
234
+ os.close(fd)
235
+ try:
236
+ total = 0
237
+ with zipfile.ZipFile(temporary, 'w', compression=zipfile.ZIP_STORED, allowZip64=True) as archive:
238
+ for name, (item, expected_digest) in inputs.items():
239
+ digest = hashlib.sha256()
240
+ size = 0
241
+ with (io.BytesIO(item) if isinstance(item, bytes) else item.open('rb')) as stream:
242
+ with archive.open(name, 'w', force_zip64=True) as dest:
243
+ while chunk := stream.read(CHUNK):
244
+ size += len(chunk)
245
+ total += len(chunk)
246
+ if total > max_total_size:
247
+ raise PackageError('Source exceeds total size limit')
248
+ dest.write(chunk)
249
+ digest.update(chunk)
250
+ if expected_digest is not None and digest.hexdigest() != expected_digest:
251
+ raise PackageError(f'External weight checksum mismatch: {name}')
252
+ manifest['files'][name] = {'sha256': digest.hexdigest(), 'size': size}
253
+ archive.writestr('manifest.json', json.dumps(manifest, sort_keys=True, indent=2) + '\n')
254
+ validate(temporary, max_total_size=max_total_size, max_json_size=max_json_size)
255
+ os.link(temporary, output) # Atomic publication with no overwrite.
256
+ return output
257
+ finally:
258
+ os.unlink(temporary)
fxbex/replay.py ADDED
@@ -0,0 +1,209 @@
1
+ """Exact-request replay of SDK Runnable bytes, not standalone native EDF execution."""
2
+ from contextlib import contextmanager
3
+ from dataclasses import dataclass, fields, is_dataclass
4
+ from enum import Enum
5
+ import hashlib
6
+ import importlib
7
+ import importlib.metadata
8
+ import json
9
+ from pathlib import Path
10
+ import platform
11
+ import re
12
+
13
+
14
+ class ReplayError(RuntimeError):
15
+ pass
16
+
17
+
18
+ @dataclass
19
+ class ReplayStats:
20
+ compile_calls: int = 0
21
+ replay_hits: int = 0
22
+
23
+
24
+ def _json(value):
25
+ return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False)
26
+
27
+
28
+ def _hash(data):
29
+ return hashlib.sha256(data).hexdigest()
30
+
31
+
32
+ def _compatibility(torch, sdk):
33
+ versions = {}
34
+ for name in ('furiosa-torch', 'furiosa-tcc'):
35
+ try:
36
+ versions[name] = importlib.metadata.version(name)
37
+ except importlib.metadata.PackageNotFoundError:
38
+ versions[name] = None
39
+ sources = {}
40
+ if getattr(sdk, '__file__', None):
41
+ directory = Path(sdk.__file__).parent
42
+ for relative in ('compiler/__init__.py', '_module/runnable.py', '_backend/torch_compile.py'):
43
+ path = directory / relative
44
+ sources[relative] = _hash(path.read_bytes()) if path.is_file() else None
45
+ return {'python': platform.python_version(), 'platform': platform.platform(),
46
+ 'torch': torch.__version__, 'sdk': versions, 'sdk_sources': sources}
47
+
48
+
49
+ def _value(value, torch, *, tensor_data=False):
50
+ if value is None or type(value) in (str, int, bool):
51
+ return value
52
+ if type(value) is float:
53
+ return {'float': value.hex()}
54
+ if isinstance(value, torch.fx.Node):
55
+ return {'node': value.name}
56
+ if isinstance(value, torch.Tensor):
57
+ result = {'tensor': {'shape': [_value(x, torch) for x in value.shape],
58
+ 'stride': [_value(x, torch) for x in value.stride()],
59
+ 'dtype': str(value.dtype), 'device': str(value.device),
60
+ 'layout': str(value.layout), 'requires_grad': value.requires_grad,
61
+ 'storage_offset': _value(value.storage_offset(), torch),
62
+ 'conjugate': value.is_conj(), 'negative': value.is_neg()}}
63
+ if tensor_data:
64
+ if value.numel() * value.element_size() > 1024 * 1024:
65
+ raise ReplayError('Constant tensor exceeds 1 MiB; lift it into an input')
66
+ if value.device.type != 'cpu' or value.layout != torch.strided:
67
+ raise ReplayError('Only dense CPU tensor constants are supported')
68
+ try:
69
+ raw = value.detach().contiguous().reshape(-1).view(torch.uint8).numpy().tobytes()
70
+ except Exception as exc:
71
+ raise ReplayError('Cannot read tensor constant') from exc
72
+ result['sha256'] = _hash(raw)
73
+ return result
74
+ if isinstance(value, (torch.dtype, torch.device, torch.layout, torch.memory_format)):
75
+ return {'torch_type': str(value)}
76
+ if isinstance(value, Enum):
77
+ return {'enum': type(value).__module__ + '.' + type(value).__qualname__, 'name': value.name}
78
+ if is_dataclass(value) and not isinstance(value, type):
79
+ return {'dataclass': type(value).__module__ + '.' + type(value).__qualname__,
80
+ 'fields': {f.name: _value(getattr(value, f.name), torch, tensor_data=tensor_data)
81
+ for f in fields(value)}}
82
+ if type(value) in (list, tuple, torch.fx.immutable_collections.immutable_list):
83
+ return {type(value).__name__: [_value(x, torch, tensor_data=tensor_data) for x in value]}
84
+ if type(value) in (dict, torch.fx.immutable_collections.immutable_dict) and all(type(k) is str for k in value):
85
+ return {k: _value(v, torch, tensor_data=tensor_data) for k, v in value.items()}
86
+ if isinstance(value, slice):
87
+ return {'slice': [_value(v, torch) for v in (value.start, value.stop, value.step)]}
88
+ raise ReplayError(f'Unsupported key value: {type(value).__module__}.{type(value).__qualname__}')
89
+
90
+
91
+ def _request(ep, config, task_name, torch):
92
+ if config is not None:
93
+ if not type(config).__module__.startswith('furiosa.'):
94
+ raise ReplayError('Unsupported compiler configuration type')
95
+ config_text = str(config)
96
+ if re.search(r' at 0x[0-9a-fA-F]+', config_text):
97
+ raise ReplayError('Compiler configuration has no stable string form')
98
+ config = {'type': type(config).__module__ + '.' + type(config).__qualname__, 'value': config_text}
99
+ nodes = []
100
+ for node in ep.graph.nodes:
101
+ target = node.target
102
+ if not isinstance(target, str):
103
+ if isinstance(target, (torch._ops.OpOverload, torch._ops.OpOverloadPacket)):
104
+ target = str(target)
105
+ elif getattr(target, '__module__', None) in ('operator', '_operator', 'builtins'):
106
+ target = target.__module__ + '.' + target.__qualname__
107
+ else:
108
+ raise ReplayError(f'Unsupported graph target: {target!r}')
109
+ if node.op in ('get_attr', 'call_module'):
110
+ raise ReplayError('Graph attributes/modules must be lifted into explicit inputs')
111
+ nodes.append({'name': node.name, 'op': node.op, 'target': target,
112
+ 'args': _value(node.args, torch, tensor_data=True),
113
+ 'kwargs': _value(node.kwargs, torch, tensor_data=True),
114
+ 'value': _value(node.meta.get('val'), torch)})
115
+ return {'nodes': nodes, 'signature': _value(ep.graph_signature, torch),
116
+ 'constants': _value(ep.constants, torch, tensor_data=True),
117
+ 'config': config}
118
+
119
+
120
+ @contextmanager
121
+ def _session(root, recording, guard_native=True):
122
+ torch = importlib.import_module('torch')
123
+ compiler = importlib.import_module('furiosa.torch.compiler')
124
+ sdk = importlib.import_module('furiosa.torch')
125
+ ir = sdk.ir
126
+ root = Path(root)
127
+ plan_path = root / 'execution_plan.json'
128
+ compatibility = _compatibility(torch, sdk)
129
+ if recording:
130
+ if plan_path.exists():
131
+ raise ReplayError('Refusing to overwrite an existing execution plan')
132
+ (root / 'edfs').mkdir(parents=True, exist_ok=True)
133
+ plan = {'format': 'sdk-runnable', 'version': 1, 'compatibility': compatibility, 'steps': []}
134
+ else:
135
+ plan = json.loads(plan_path.read_text())
136
+ if (plan.get('format') != 'sdk-runnable' or plan.get('version') != 1
137
+ or plan.get('compatibility') != compatibility):
138
+ raise ReplayError('Runnable plan format or environment compatibility mismatch')
139
+ records = {}
140
+ for record in plan['steps']:
141
+ key = record['key']
142
+ if key in records or _hash(_json(record['args']).encode()) != key:
143
+ raise ReplayError('Duplicate or invalid request key')
144
+ if record['path'] != f'edfs/{key}.runnable':
145
+ raise ReplayError('Invalid runnable path')
146
+ records[key] = record
147
+ original = compiler.compile
148
+ stats = ReplayStats()
149
+
150
+ def compile(ep, compiler_config=None, *, task_name=None):
151
+ effective_config = compiler_config if compiler_config is not None else sdk.config.get_compiler_config()
152
+ args = _request(ep, effective_config, task_name, torch)
153
+ key = _hash(_json(args).encode())
154
+ if recording:
155
+ stats.compile_calls += 1
156
+ runnable = original(ep, compiler_config, task_name=task_name)
157
+ data = bytes(runnable.serialize())
158
+ record = {'key': key, 'path': f'edfs/{key}.runnable', 'sha256': _hash(data), 'args': args}
159
+ if key in records and records[key] != record:
160
+ raise ReplayError('Identical request produced different runnable bytes')
161
+ if key not in records:
162
+ path = root / record['path']
163
+ if path.exists() and path.read_bytes() != data:
164
+ raise ReplayError('Conflicting existing runnable file')
165
+ path.write_bytes(data)
166
+ records[key] = record
167
+ plan['steps'].append(record)
168
+ return runnable
169
+ if key not in records:
170
+ raise ReplayError(f'No recorded runnable for request {key}; compilation is forbidden')
171
+ record = records[key]
172
+ path = root / record['path']
173
+ if path.resolve().parent != (root / 'edfs').resolve() or path.is_symlink():
174
+ raise ReplayError('Runnable path escapes package')
175
+ data = path.read_bytes()
176
+ if _hash(data) != record['sha256']:
177
+ raise ReplayError('Runnable checksum mismatch')
178
+ runnable = ir.Runnable.deserialize(data)
179
+ stats.replay_hits += 1
180
+ return runnable
181
+
182
+ def forbidden(*args, **kwargs):
183
+ raise ReplayError('Native compiler entry point is forbidden during replay')
184
+
185
+ patches = [(compiler, 'compile', original)]
186
+ try:
187
+ compiler.compile = compile
188
+ if not recording and guard_native:
189
+ native = importlib.import_module('furiosa.torch._C')
190
+ for name in ('compile', 'compile_raw_dfg'):
191
+ if hasattr(native.compiler, name):
192
+ patches.append((native.compiler, name, getattr(native.compiler, name)))
193
+ setattr(native.compiler, name, forbidden)
194
+ yield stats
195
+ if recording:
196
+ plan_path.write_text(_json(plan) + '\n')
197
+ finally:
198
+ for obj, name, value in reversed(patches):
199
+ setattr(obj, name, value)
200
+
201
+
202
+ def capture(root):
203
+ """Capture public compile calls and write an execution plan on successful exit."""
204
+ return _session(root, True)
205
+
206
+
207
+ def replay(root, *, guard_native=True):
208
+ """Restore exact captured requests; tracing/lowering still occurs, compilation cannot."""
209
+ return _session(root, False, guard_native)