Skip to content

Commit 7d2ca33

Browse files
authored
Merge pull request #310 from bbopen/feat/0.10-torch-dtypes
feat(codec): torch bfloat16 via exact upcast + source provenance
2 parents 71e21ec + cb99ed6 commit 7d2ca33

9 files changed

Lines changed: 184 additions & 13 deletions

File tree

docs/codec-envelopes.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,10 @@ The target is `torch.Tensor`. CPU tensors work by default. Schema sketch:
7878
"codecVersion": 1,
7979
"encoding": "ndarray",
8080
"shape": [ ... ],
81-
"dtype": "float32",
81+
"dtype": "torch.float32",
8282
"device": "cpu" | "cuda:0" | ...,
83+
"sourceDtype": "torch.bfloat16", // optional; original dtype before conversion
84+
"sourceDevice": "cuda:0", // optional; original device before CPU copy
8385

8486
// Nested ndarray envelope:
8587
"value": {
@@ -93,6 +95,9 @@ The target is `torch.Tensor`. CPU tensors work by default. Schema sketch:
9395
}
9496
```
9597

98+
`torch.bfloat16` tensors are upcast exactly to `torch.float32` for transport,
99+
with the original dtype recorded in `sourceDtype`.
100+
96101
Set `TYWRAP_TORCH_ALLOW_COPY=1` only when the CPU transfer or contiguous copy
97102
is acceptable for the call.
98103

docs/public/llms-full.txt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2239,6 +2239,10 @@ interface TorchTensor {
22392239
shape: number[];
22402240
dtype?: string;
22412241
device?: string;
2242+
/** Original dtype when the bridge upcast the tensor (e.g. torch.bfloat16). */
2243+
sourceDtype?: string;
2244+
/** Original device when TYWRAP_TORCH_ALLOW_COPY=1 moved the tensor to CPU. */
2245+
sourceDevice?: string;
22422246
}
22432247
```
22442248

@@ -2401,8 +2405,10 @@ The target is `torch.Tensor`. CPU tensors work by default. Schema sketch:
24012405
"codecVersion": 1,
24022406
"encoding": "ndarray",
24032407
"shape": [ ... ],
2404-
"dtype": "float32",
2408+
"dtype": "torch.float32",
24052409
"device": "cpu" | "cuda:0" | ...,
2410+
"sourceDtype": "torch.bfloat16", // optional; original dtype before conversion
2411+
"sourceDevice": "cuda:0", // optional; original device before CPU copy
24062412

24072413
// Nested ndarray envelope:
24082414
"value": {
@@ -2416,6 +2422,9 @@ The target is `torch.Tensor`. CPU tensors work by default. Schema sketch:
24162422
}
24172423
```
24182424

2425+
`torch.bfloat16` tensors are upcast exactly to `torch.float32` for transport,
2426+
with the original dtype recorded in `sourceDtype`.
2427+
24192428
Set `TYWRAP_TORCH_ALLOW_COPY=1` only when the CPU transfer or contiguous copy
24202429
is acceptable for the call.
24212430

docs/reference/type-mapping.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ interface TorchTensor {
126126
shape: number[];
127127
dtype?: string;
128128
device?: string;
129+
/** Original dtype when the bridge upcast the tensor (e.g. torch.bfloat16). */
130+
sourceDtype?: string;
131+
/** Original device when TYWRAP_TORCH_ALLOW_COPY=1 moved the tensor to CPU. */
132+
sourceDevice?: string;
129133
}
130134
```
131135

runtime/tywrap_bridge_core.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,8 @@ def serialize_torch_tensor(obj, *, force_json_markers, torch_allow_copy=False):
656656
import torch # already importable: is_torch_tensor() gated the dispatch
657657

658658
tensor = obj.detach()
659+
source_device = None
660+
source_dtype = None
659661

660662
# Sparse tensors (COO/CSR/CSC/BSR/BSC -> any non-strided layout) have no dense
661663
# numpy representation without a densify step, which is not the round-trip this
@@ -703,19 +705,23 @@ def serialize_torch_tensor(obj, *, force_json_markers, torch_allow_copy=False):
703705
raise RuntimeError(
704706
'Torch tensor is on a non-CPU device; set TYWRAP_TORCH_ALLOW_COPY=1 to allow CPU transfer'
705707
)
708+
source_device = str(tensor.device)
706709
tensor = tensor.to('cpu')
707710
if hasattr(tensor, 'is_contiguous') and not tensor.is_contiguous():
708711
if not torch_allow_copy:
709712
raise RuntimeError(
710713
'Torch tensor is not contiguous; set TYWRAP_TORCH_ALLOW_COPY=1 to allow contiguous copy'
711714
)
712715
tensor = tensor.contiguous()
716+
if tensor.dtype == torch.bfloat16:
717+
source_dtype = str(tensor.dtype)
718+
tensor = tensor.float()
713719
try:
714720
arr = tensor.numpy()
715721
except Exception as exc:
716722
raise RuntimeError('Failed to convert torch.Tensor to numpy') from exc
717723

718-
return {
724+
envelope = {
719725
'__tywrap__': 'torch.tensor',
720726
'codecVersion': CODEC_VERSION,
721727
'encoding': 'ndarray',
@@ -724,6 +730,11 @@ def serialize_torch_tensor(obj, *, force_json_markers, torch_allow_copy=False):
724730
'dtype': str(tensor.dtype),
725731
'device': str(tensor.device),
726732
}
733+
if source_dtype is not None:
734+
envelope['sourceDtype'] = source_dtype
735+
if source_device is not None:
736+
envelope['sourceDevice'] = source_device
737+
return envelope
727738

728739

729740
def serialize_sklearn_estimator(obj):

src/runtime/pyodide-bootstrap-core.generated.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

src/utils/codec.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export interface TorchTensor {
3333
shape?: readonly number[];
3434
dtype?: string;
3535
device?: string;
36+
sourceDtype?: string;
37+
sourceDevice?: string;
3638
}
3739

3840
export interface SklearnEstimator {
@@ -93,6 +95,8 @@ export type ValueEnvelope =
9395
readonly shape?: readonly number[];
9496
readonly dtype?: string;
9597
readonly device?: string;
98+
readonly sourceDtype?: string;
99+
readonly sourceDevice?: string;
96100
}
97101
| {
98102
readonly __tywrap__: 'sklearn.estimator';
@@ -1013,18 +1017,47 @@ const decodeTorchTensorEnvelope: EnvelopeHandler = <T>(
10131017
);
10141018
}
10151019
const device = typeof deviceValue === 'string' ? deviceValue : undefined;
1020+
const sourceDtypeValue = value.sourceDtype;
1021+
if (
1022+
sourceDtypeValue !== undefined &&
1023+
(typeof sourceDtypeValue !== 'string' || sourceDtypeValue.length === 0)
1024+
) {
1025+
throw new Error(
1026+
'Invalid torch.tensor envelope: sourceDtype must be a non-empty string when provided'
1027+
);
1028+
}
1029+
const sourceDtype = typeof sourceDtypeValue === 'string' ? sourceDtypeValue : undefined;
1030+
const sourceDeviceValue = value.sourceDevice;
1031+
if (
1032+
sourceDeviceValue !== undefined &&
1033+
(typeof sourceDeviceValue !== 'string' || sourceDeviceValue.length === 0)
1034+
) {
1035+
throw new Error(
1036+
'Invalid torch.tensor envelope: sourceDevice must be a non-empty string when provided'
1037+
);
1038+
}
1039+
const sourceDevice = typeof sourceDeviceValue === 'string' ? sourceDeviceValue : undefined;
1040+
1041+
const tensor = (data: unknown): TorchTensor => ({
1042+
data,
1043+
shape,
1044+
dtype,
1045+
device,
1046+
...(sourceDtype === undefined ? {} : { sourceDtype }),
1047+
...(sourceDevice === undefined ? {} : { sourceDevice }),
1048+
});
10161049

10171050
const decoded = recurse(nested);
10181051
if (isPromiseLike(decoded)) {
10191052
return decoded.then(data =>
1020-
tagDecodedShape({ data, shape, dtype, device } satisfies TorchTensor, {
1053+
tagDecodedShape(tensor(data), {
10211054
marker: 'torch.tensor',
10221055
dims: shape?.length,
10231056
dtype,
10241057
})
10251058
) as Promise<T | unknown>;
10261059
}
1027-
return tagDecodedShape({ data: decoded, shape, dtype, device } satisfies TorchTensor, {
1060+
return tagDecodedShape(tensor(decoded), {
10281061
marker: 'torch.tensor',
10291062
dims: shape?.length,
10301063
dtype,

test/codec-envelope-validation.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,62 @@ describe('#234 codec envelope re-validation (JS decoder)', () => {
678678
).toThrow(/device must be a non-empty string/);
679679
});
680680

681+
it('preserves dtype and device provenance without changing transported metadata', () => {
682+
const result = decodeValue({
683+
__tywrap__: 'torch.tensor',
684+
codecVersion: 1,
685+
encoding: 'ndarray',
686+
value: {
687+
__tywrap__: 'ndarray',
688+
codecVersion: 1,
689+
encoding: 'json',
690+
data: [1, -2.5, 3.140625],
691+
shape: [3],
692+
dtype: 'float32',
693+
},
694+
shape: [3],
695+
dtype: 'torch.float32',
696+
device: 'cpu',
697+
sourceDtype: 'torch.bfloat16',
698+
sourceDevice: 'cuda:0',
699+
});
700+
701+
expect(result).toMatchObject({
702+
data: [1, -2.5, 3.140625],
703+
shape: [3],
704+
dtype: 'torch.float32',
705+
device: 'cpu',
706+
sourceDtype: 'torch.bfloat16',
707+
sourceDevice: 'cuda:0',
708+
});
709+
});
710+
711+
it.each([
712+
['sourceDtype', ''],
713+
['sourceDtype', 123],
714+
['sourceDevice', ''],
715+
['sourceDevice', false],
716+
] as const)('rejects invalid optional %s provenance', (field, invalidValue) => {
717+
expect(() =>
718+
decodeValue({
719+
__tywrap__: 'torch.tensor',
720+
codecVersion: 1,
721+
encoding: 'ndarray',
722+
value: {
723+
__tywrap__: 'ndarray',
724+
codecVersion: 1,
725+
encoding: 'json',
726+
data: [1],
727+
shape: [1],
728+
dtype: 'float32',
729+
},
730+
shape: [1],
731+
dtype: 'torch.float32',
732+
[field]: invalidValue,
733+
})
734+
).toThrow(new RegExp(`${field} must be a non-empty string when provided`));
735+
});
736+
681737
it('rejects a nested value that is not an ndarray envelope', () => {
682738
expect(() =>
683739
decodeValue({

test/menagerie/fixtures/library_torture.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,18 @@ def torch_int64() -> object:
253253
return torch.tensor([1, -2], dtype=torch.int64)
254254

255255

256+
def torch_dtype(dtype_name: str) -> object:
257+
import torch
258+
259+
if dtype_name == "uint8":
260+
values = [0, 3, 255]
261+
elif dtype_name.startswith("int"):
262+
values = [1, -2]
263+
else:
264+
values = [1.5, -2.25]
265+
return torch.tensor(values, dtype=getattr(torch, dtype_name))
266+
267+
256268
def torch_scalar() -> object:
257269
import torch
258270

@@ -262,7 +274,9 @@ def torch_scalar() -> object:
262274
def torch_bfloat16() -> object:
263275
import torch
264276

265-
return torch.tensor([1.5], dtype=torch.bfloat16)
277+
return torch.tensor(
278+
[1.0, -2.5, 3.140625, 2**16, 2**32], dtype=torch.bfloat16
279+
)
266280

267281

268282
def torch_sparse() -> object:
@@ -277,6 +291,12 @@ def torch_quantized() -> object:
277291
return torch.quantize_per_tensor(torch.tensor([1.0, 2.0]), 0.1, 0, torch.qint8)
278292

279293

294+
def torch_meta() -> object:
295+
import torch
296+
297+
return torch.empty(3, device="meta")
298+
299+
280300
def torch_complex() -> object:
281301
import torch
282302

test/menagerie/manifest.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,22 @@ export const RUNTIME_CATALOGUE: readonly CatalogueRow[] = [
697697
value: { data: [1, -2], shape: [2], dtype: 'torch.int64', device: 'cpu' },
698698
},
699699
}),
700+
...(['float64', 'int8', 'int16', 'int32', 'uint8'] as const).map(dtype => {
701+
const data = dtype === 'uint8' ? [0, 3, 255] : dtype.startsWith('int') ? [1, -2] : [1.5, -2.25];
702+
return libraryRow({
703+
id: `torch-${dtype}`,
704+
call: `torch_dtype('${dtype}')`,
705+
functionName: 'torch_dtype',
706+
args: [dtype],
707+
requires: ['torch', 'pyarrow'],
708+
status: 'EXPECTED_OK',
709+
currentBehavior: `A dense ${dtype} CPU tensor round-trips with its declared dtype.`,
710+
expected: {
711+
kind: 'match',
712+
value: { data, shape: [data.length], dtype: `torch.${dtype}`, device: 'cpu' },
713+
},
714+
});
715+
}),
700716
libraryRow({
701717
id: 'torch-scalar',
702718
call: 'torch_scalar()',
@@ -712,17 +728,26 @@ export const RUNTIME_CATALOGUE: readonly CatalogueRow[] = [
712728
id: 'torch-bfloat16',
713729
call: 'torch_bfloat16()',
714730
requires: ['torch', 'pyarrow'],
715-
status: 'LOUD_FAIL',
716-
currentBehavior: 'NumPy conversion rejects bfloat16.',
717-
expected: error(/Failed to convert torch\.Tensor to numpy/i),
731+
status: 'EXPECTED_OK',
732+
currentBehavior: 'bfloat16 values upcast exactly to float32 and retain their source dtype.',
733+
expected: {
734+
kind: 'match',
735+
value: {
736+
data: [1, -2.5, 3.140625, 2 ** 16, 2 ** 32],
737+
shape: [5],
738+
dtype: 'torch.float32',
739+
sourceDtype: 'torch.bfloat16',
740+
device: 'cpu',
741+
},
742+
},
718743
}),
719744
libraryRow({
720745
id: 'torch-sparse',
721746
call: 'torch_sparse()',
722747
requires: ['torch'],
723748
status: 'LOUD_FAIL',
724749
currentBehavior: 'Sparse tensor layouts are rejected explicitly.',
725-
expected: error(/sparse tensors are not supported/i),
750+
expected: error(/convert to a dense CPU tensor explicitly.*tensor\.to_dense/i),
726751
}),
727752
libraryRow({
728753
id: 'torch-quantized',
@@ -731,15 +756,23 @@ export const RUNTIME_CATALOGUE: readonly CatalogueRow[] = [
731756
featureProbe: 'import torch; print("1" if hasattr(torch, "quantize_per_tensor") else "0")',
732757
status: 'LOUD_FAIL',
733758
currentBehavior: 'Quantized tensors are rejected explicitly.',
734-
expected: error(/quantized tensors are not supported/i),
759+
expected: error(/dequantize explicitly.*tensor\.dequantize/i),
760+
}),
761+
libraryRow({
762+
id: 'torch-meta',
763+
call: 'torch_meta()',
764+
requires: ['torch'],
765+
status: 'LOUD_FAIL',
766+
currentBehavior: 'Meta tensors are rejected explicitly.',
767+
expected: error(/materialize the tensor on a real device before returning/i),
735768
}),
736769
libraryRow({
737770
id: 'torch-complex',
738771
call: 'torch_complex()',
739772
requires: ['torch'],
740773
status: 'LOUD_FAIL',
741774
currentBehavior: 'Complex tensors are rejected explicitly.',
742-
expected: error(/complex tensors are not supported/i),
775+
expected: error(/split into real\/imag components explicitly before returning/i),
743776
}),
744777

745778
libraryRow({

0 commit comments

Comments
 (0)