Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,6 @@ test.php
tensor
test_img.jpg
composer.lock
vendor
vendor
docker
docker-compose.yml
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,3 +321,16 @@ $xCpu = $x->cpu();
```

> **Heads up:** every operation works on arrays that live on the *same* device. Adding a CPU array to a GPU array throws an exception — copy one of them across with `->cpu()` or `->gpu()` first.

## Changing datatypes at runtime

```php
$x = new NDArray([1.5, 2.5, 3.5], 'float32');

$x->cast('float16'); // converts $x in place (alias: toDataType())
echo $x->dataType(); // "float16"

$y = NumPower::copy($x)->cast('int32'); // cast a copy to keep the original
```

`cast()` returns `$this`, so it can be chained. `setDataType()` does the same conversion but returns nothing.
113 changes: 81 additions & 32 deletions numpower.c
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,53 @@ PHP_METHOD(NDArray, gpu) {
#endif
}

/**
* Casts the NDArray behind `obj_zval` to `dtype` in place, keeping the
* PHP object and its uuid. Shared by setDataType() and cast().
*
* @return SUCCESS, or FAILURE with a PHP exception pending.
*/
static int ndarray_set_datatype_inplace(zval *obj_zval, const char *dtype, size_t dtypeLen) {
NDArray *ndarray = NDArrayFactory_restoreFromZval(obj_zval);
if (ndarray == NULL) {
return FAILURE;
}

/* An embedded NUL would make "float16\0junk" look like "float16" to
the C string comparison inside type_canonicalize(). */
const char *canonical = strlen(dtype) == dtypeLen ? type_canonicalize(dtype) : NULL;
if (canonical == NULL) {
zend_throw_error(NULL,
"Invalid data type '%s'. Supported: float4, float8, float16, "
"float32, float64, float128, int8, uint8, int16, uint16, "
"int32, uint32, int64, uint64", dtype);
return FAILURE;
}

/* Fast path: already this dtype — true no-op, no allocation, no swap. */
if (is_type(NDArray_TYPE(ndarray), canonical)) {
return SUCCESS;
}

/* NDArray_AsType always returns a fresh, caller-owned, unbuffered
NDArray; the source `ndarray` is left intact. We then swap the new
array into the same buffer slot (preserving the object's uuid) and
release the previous occupant. NDArray_FREE is refcount-aware: if a
live slice still references the old array, its data survives until
the last view is freed. */
NDArray *casted = NDArray_AsType(ndarray, canonical);
if (casted == NULL) {
return FAILURE; /* Original NDArray remains untouched. */
}

int uuid = NDArray_UUID(ndarray);
NDArray *prev = buffer_replace(uuid, casted);
if (prev) {
NDArray_FREE(prev);
}
return SUCCESS;
}

ZEND_BEGIN_ARG_INFO(arginfo_setdtype, 0)
ZEND_ARG_INFO(0, dtype)
ZEND_END_ARG_INFO();
Expand All @@ -323,47 +370,47 @@ ZEND_END_ARG_INFO();
PHP_METHOD(NDArray, setDataType) {
char *dtype;
size_t dtypeLen = 0;
zval *obj_zval = getThis();

ZEND_PARSE_PARAMETERS_START(1, 1)
Z_PARAM_STRING(dtype, dtypeLen)
ZEND_PARSE_PARAMETERS_END();

NDArray *ndarray = NDArrayFactory_restoreFromZval(obj_zval);
if (ndarray == NULL) {
return;
}

const char *canonical = type_canonicalize(dtype);
if (canonical == NULL) {
zend_throw_error(NULL,
"Invalid data type '%s'. Supported: float4, float8, float16, "
"float32, float64, float128, int8, uint8, int16, uint16, "
"int32, uint32, int64, uint64", dtype);
return;
}
ndarray_set_datatype_inplace(getThis(), dtype, dtypeLen);
}

/* Fast path: already this dtype — true no-op, no allocation, no swap. */
if (is_type(NDArray_TYPE(ndarray), canonical)) {
return;
}
ZEND_BEGIN_ARG_INFO(arginfo_cast, 0)
ZEND_ARG_INFO(0, dtype)
ZEND_END_ARG_INFO();
/**
* @brief Casts this NDArray to another data type in place and returns it.
*
* ```
* cast(string $dtype): NDArray
* toDataType(string $dtype): NDArray
* ```
*
* Same in-place semantics as `setDataType()` (the PHP object and every
* alias of it see the new dtype), but returns `$this` so calls can be
* chained: `$x->cast('float16')->toArray()`. To keep the original, cast
* a copy: `NumPower::copy($x)->cast('float16')`.
*
* @param dtype Target dtype alias.
*
* @throws \Error If the dtype is unknown.
*/
PHP_METHOD(NDArray, cast) {
char *dtype;
size_t dtypeLen = 0;
zval *obj_zval = getThis();

/* NDArray_AsType always returns a fresh, caller-owned, unbuffered
NDArray; the source `ndarray` is left intact. We then swap the new
array into the same buffer slot (preserving the object's uuid) and
release the previous occupant. NDArray_FREE is refcount-aware: if a
live slice still references the old array, its data survives until
the last view is freed. */
NDArray *casted = NDArray_AsType(ndarray, canonical);
if (casted == NULL) {
return; /* Original NDArray remains untouched. */
}
ZEND_PARSE_PARAMETERS_START(1, 1)
Z_PARAM_STRING(dtype, dtypeLen)
ZEND_PARSE_PARAMETERS_END();

int uuid = NDArray_UUID(ndarray);
NDArray *prev = buffer_replace(uuid, casted);
if (prev) {
NDArray_FREE(prev);
if (ndarray_set_datatype_inplace(obj_zval, dtype, dtypeLen) == FAILURE) {
return;
}
ZVAL_COPY(return_value, obj_zval);
}

ZEND_BEGIN_ARG_INFO(arginfo_datatype, 0)
Expand Down Expand Up @@ -7526,6 +7573,8 @@ static const zend_function_entry class_NDArray_methods[] = {
ZEND_ME(NDArray, isGPU, arginfo_is_gpu, ZEND_ACC_PUBLIC)
ZEND_ME(NDArray, setDataType, arginfo_setdtype, ZEND_ACC_PUBLIC)
ZEND_ME(NDArray, dataType, arginfo_datatype, ZEND_ACC_PUBLIC)
ZEND_ME(NDArray, cast, arginfo_cast, ZEND_ACC_PUBLIC)
ZEND_MALIAS(NDArray, toDataType, cast, arginfo_cast, ZEND_ACC_PUBLIC)

ZEND_ME(NDArray, size, arginfo_size, ZEND_ACC_PUBLIC)
ZEND_ME(NDArray, count, arginfo_count, ZEND_ACC_PUBLIC)
Expand Down
50 changes: 50 additions & 0 deletions tests/types/031-cast.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
--TEST--
NDArray::cast() / toDataType() change the dtype in place and return $this
--FILE--
<?php

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally unrelated, but what are your thoughts on either

A. Converting tests that only test the public API to PHP Unit
B. Only testing the extension through the public API, i.e. subsystems are tested indirectly, and dropping all these .phpt style tests

?

/* 1. Ticket example: a bare call changes $ndarray itself */
$a = new NDArray([1.5, 2.5, 3.7], 'float32');
$alias = $a;
$id = spl_object_id($a);
$a->cast('int32');
echo $a->dataType(), ' ', json_encode($a->toArray()), "\n";
echo spl_object_id($a) === $id ? "same object\n" : "NEW OBJECT\n";
echo $alias->dataType(), " (alias)\n";

/* 2. toDataType() alias; return value is $this */
$ret = $a->toDataType('float64');
echo $ret === $a ? "returns \$this\n" : "returns other\n";
echo $a->dataType(), ' ', json_encode($a->toArray()), "\n";

/* 3. Ticket example with float16; chaining */
$h = new NDArray([1.5, 2.5, 3.7], 'float32');
echo json_encode($h->cast('float16')->toArray()), ' ', $h->dataType(), "\n";

/* 4. Same dtype: no-op, still returns $this */
echo $h->cast('float16') === $h ? "same-type ok\n" : "same-type BAD\n";

/* 5. Keep the original by casting a copy */
$src = new NDArray([1.5, 2.5], 'float32');
$dst = NumPower::copy($src)->cast('int8');
echo $src->dataType(), ' ', json_encode($src->toArray()), ' | ',
$dst->dataType(), ' ', json_encode($dst->toArray()), "\n";

/* 6. Invalid dtype throws, array unchanged */
try {
$src->cast('bogus');
} catch (Error $e) {
echo "Error caught\n";
}
echo $src->dataType(), "\n";
?>
--EXPECT--
int32 [1,2,3]
same object
int32 (alias)
returns $this
float64 [1,2,3]
[1.5,2.5,3.69921875] float16
same-type ok
float32 [1.5,2.5] | int8 [1,2]
Error caught
float32
16 changes: 16 additions & 0 deletions tests/types/032-cast-gpu.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
--TEST--
NDArray::cast() on a GPU array converts in place and stays on the GPU
--SKIPIF--
<?php try { (new NDArray([1.0]))->gpu(); } catch (Error $e) { die('skip ' . $e->getMessage()); } ?>
--FILE--
<?php
$g = (new NDArray([1.5, 2.5, 3.5], 'float32'))->gpu();
$ret = $g->cast('int32');
echo $ret === $g ? "returns \$this\n" : "returns other\n";
echo $g->isGPU() ? "gpu\n" : "cpu\n";
echo $g->dataType(), ' ', json_encode($g->cpu()->toArray()), "\n";
?>
--EXPECT--
returns $this
gpu
int32 [1,2,3]
42 changes: 42 additions & 0 deletions tests/types/033-cast-invalid-args.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
--TEST--
NDArray::cast() / setDataType() reject invalid arguments and leave the array unchanged
--FILE--
<?php
$cases = [
'no args' => fn($a) => $a->cast(),
'two args' => fn($a) => $a->cast('int8', 'int8'),
'array' => fn($a) => $a->cast([]),
'object' => fn($a) => $a->cast(new stdClass()),
'empty string' => fn($a) => $a->cast(''),
'unknown' => fn($a) => $a->cast('float17'),
'wrong case' => fn($a) => $a->cast('Float32'),
'trailing space' => fn($a) => $a->cast('float16 '),
'NUL byte' => fn($a) => $a->cast("float16\0junk"),
'NUL byte (set)' => fn($a) => $a->setDataType("int8\0"),
'numeric' => fn($a) => $a->cast(123),
];

foreach ($cases as $label => $fn) {
$a = new NDArray([1.5, 2.5], 'float32');
try {
$fn($a);
$res = 'no exception';
} catch (Throwable $e) {
$res = get_class($e);
}
$intact = $a->dataType() === 'float32' && $a->toArray() === [1.5, 2.5];
echo str_pad($label, 15), ' ', $res, ' ', $intact ? 'intact' : 'CHANGED', "\n";
}
?>
--EXPECT--
no args ArgumentCountError intact
two args ArgumentCountError intact
array TypeError intact
object TypeError intact
empty string Error intact
unknown Error intact
wrong case Error intact
trailing space Error intact
NUL byte Error intact
NUL byte (set) Error intact
numeric Error intact
64 changes: 64 additions & 0 deletions tests/types/034-cast-boundaries.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
--TEST--
NDArray::cast() boundaries: every dtype pair, 0-D, empty and N-D arrays
--FILE--
<?php
/* 1. Every source dtype to every target dtype (14 x 14). Values 1..3 are
exactly representable in all of them, including float4/float8.
float128 and uint64 come back as PHP strings to keep full precision. */
$types = ['float4', 'float8', 'float16', 'float32', 'float64', 'float128',
'int8', 'uint8', 'int16', 'uint16', 'int32', 'uint32', 'int64', 'uint64'];
$bad = [];
foreach ($types as $from) {
foreach ($types as $to) {
$a = new NDArray([1, 2, 3], $from);
$a->cast($to);
$want = match (true) {
in_array($to, ['float128', 'uint64'], true) => ['1', '2', '3'],
$to[0] === 'f' => [1.0, 2.0, 3.0],
default => [1, 2, 3],
};
if ($a->dataType() !== $to || $a->toArray() !== $want) {
$bad[] = "$from->$to";
}
}
}
echo "pairs: ", count($types) ** 2, " bad: ", json_encode($bad), "\n";

/* 2. 0-D array stays an NDArray with the new dtype */
$z = new NDArray(2.7, 'float32');
$z->cast('int32');
echo "0-D: ", get_class($z), ' ', $z->dataType(), ' ', json_encode($z->toArray()), "\n";

/* 3. Empty array */
$e = NumPower::zeros([0]);
$e->cast('int8');
echo "empty: ", $e->dataType(), ' ', json_encode($e->shape()), ' ', json_encode($e->toArray()), "\n";

/* 4. N-D shape and values preserved */
$m = NumPower::ones([2, 3, 4]);
$m->cast('int16');
$flat = array_merge(...array_merge(...$m->toArray()));
echo "3-D: ", $m->dataType(), ' ', json_encode($m->shape()), ' all ones: ',
($flat === array_fill(0, 24, 1) ? 'yes' : 'no'), "\n";

/* 5. Truncation toward zero for float -> int */
$t = new NDArray([1.9, -1.9, 0.5, -0.5], 'float64');
$t->cast('int32');
echo "truncate: ", json_encode($t->toArray()), "\n";

/* 6. Repeated casts back and forth do not leak */
$x = NumPower::ones([1000]);
$x->cast('int16');
$m0 = memory_get_usage();
for ($i = 0; $i < 10000; $i++) {
$x->cast($i % 2 ? 'int16' : 'float32');
}
echo "leak: ", (memory_get_usage() - $m0 < 64 * 1024 ? 'no' : 'yes'), "\n";
?>
--EXPECT--
pairs: 196 bad: []
0-D: NDArray int32 2
empty: int8 [0] []
3-D: int16 [2,3,4] all ones: yes
truncate: [1,-1,0,0]
leak: no
41 changes: 41 additions & 0 deletions tests/types/035-cast-views-clone.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
--TEST--
NDArray::cast() with slices and clones: only the cast object changes
--FILE--
<?php
/* 1. Casting a slice changes only the slice */
$p = new NDArray([[1.5, 2.5], [3.5, 4.5]], 'float32');
$s = $p[1];
$s->cast('int32');
echo "slice: ", $s->dataType(), ' ', json_encode($s->toArray()), "\n";
echo "parent: ", $p->dataType(), ' ', json_encode($p->toArray()), "\n";

/* 2. Casting the parent keeps an existing slice valid and unchanged */
$p = new NDArray([[1.5, 2.5], [3.5, 4.5]], 'float32');
$s = $p[0];
$p->cast('int8');
echo "parent: ", $p->dataType(), ' ', json_encode($p->toArray()), "\n";
echo "slice: ", $s->dataType(), ' ', json_encode($s->toArray()), "\n";
unset($p);
echo "slice after parent freed: ", json_encode($s->toArray()), "\n";

/* 3. A clone is independent */
$a = new NDArray([1.5, 2.5], 'float32');
$c = clone $a;
$c->cast('int8');
echo "orig: ", $a->dataType(), ' ', json_encode($a->toArray()), "\n";
echo "clone: ", $c->dataType(), ' ', json_encode($c->toArray()), "\n";

/* 4. Arithmetic works on a cast array */
$x = new NDArray([1.5, 2.5], 'float32');
$x->cast('int32');
echo "add: ", json_encode(NumPower::add($x, new NDArray([1, 1], 'int32'))->toArray()), "\n";
?>
--EXPECT--
slice: int32 [3,4]
parent: float32 [[1.5,2.5],[3.5,4.5]]
parent: int8 [[1,2],[3,4]]
slice: float32 [1.5,2.5]
slice after parent freed: [1.5,2.5]
orig: float32 [1.5,2.5]
clone: int8 [1,2]
add: [2,3]
Loading