Instance attributes synchronization #5

Open
opened 2021-01-26 17:12:39 +08:00 by pca006132 · 21 comments
Collaborator

The Problem

Confusing Behavior

Classes can contain both kernel and host functions. However, instance attributes would only be transferred to the host after the kernel is completed, so the attributes are out of sync most of the time, and could lead to confusing behaviors.

from artiq.experiment import *

class TestListRPC(EnvExperiment):
    def build(self):
        self.setattr_device("core")
        self.a = 1

    def change_a(self):
        print(self.a)
        self.a = 2
        print(self.a)

    @kernel
    def run(self):
        print(self.a)
        self.a = 3
        self.change_a()
        print(self.a)

This code produces the following output (comments added for explanation):

1
1 (This print is called from the host, although self.a is 3 in the device, the attributes are not synchronized across RPC calls)
2
3 (This print is called from the device, although self.a is 2 in the host, the attributes are not synchronized)

Even if we add kernel_invariants, it would only prohibit us from writing the line self.a = 3, but not protected from modification within a host function, as we don't (and cannot) analyze host functions.

Kernel Object Construction

Currently, it is useless to construct objects in kernel code.

For example:

class Foo:
    def __init__(self):
        self.a = 1

    def foo(self):
        print(self.a)

class TestListRPC(EnvExperiment):
    def build(self):
        self.setattr_device("core")

    @kernel
    def run(self):
        foo = Foo()
        foo.foo()

would yield the following error:

error: type <instance artiq_run_model.Foo {
                __objectid__: numpy.int32
        }> does not have an attribute 'foo'
        foo.foo()
        ~~~ ^^^  

If we just consider how we can implement this, it looks weird. The object would be stored within the device, with no host representation. What should we do when we call an RPC method? Should we create the object in the host, and somehow identify it with an ID so the data would persist across multiple RPCs? Or should we create the object in the host, and write-back its attributes to the device after the RPC is finished? What about undeclared attributes?

Solutions

Treat as a Feature

Accept it. This is not a bug, but a feature...

More Synchronization

Synchronize attributes before and after every RPCs. There are two problems here:

  1. Performance, synchronization takes time, especially when some objects have a lot of attributes or a lot of data in attributes.
  2. Undeclared attributes. Maybe we could just ignore that and say that this is not supported.

Prohibit Host Functions

Prohibit host functions on kernel classes, except for the constructor.
For classes that are constructed within kernel functions, the constructor can be marked as a kernel function to avoid RPC calls. The kernel class instance in the host would be an object without attributes, the host can only call its methods.

This sounds radical, but I think most of the code is doing something similar. There are use-cases that treat attributes as kernel invariants, but I think those can be refactored into the following form:


class SomeDriver:
    def __init__(self, *params):
        self.kernel = SomeDriverKernel(*params)
        # copy attributes...
    def some_function(self, *params):
        # if this calls a kernel function, call a kernel function
        self.kernel.some_function(*params)
        # otherwise, just run a normal host function

Basically, copy its parameters to avoid accessing the inner kernel instance attributes. Duplicated logic can be refactored into standalone portable functions, without access to the class instance.

# The Problem ## Confusing Behavior Classes can contain both kernel and host functions. However, instance attributes would only be transferred to the host after the kernel is completed, so the attributes are out of sync most of the time, and could lead to confusing behaviors. ```python from artiq.experiment import * class TestListRPC(EnvExperiment): def build(self): self.setattr_device("core") self.a = 1 def change_a(self): print(self.a) self.a = 2 print(self.a) @kernel def run(self): print(self.a) self.a = 3 self.change_a() print(self.a) ``` This code produces the following output (comments added for explanation): ``` 1 1 (This print is called from the host, although self.a is 3 in the device, the attributes are not synchronized across RPC calls) 2 3 (This print is called from the device, although self.a is 2 in the host, the attributes are not synchronized) ``` Even if we add `kernel_invariants`, it would only prohibit us from writing the line `self.a = 3`, but not protected from modification within a host function, as we don't (and cannot) analyze host functions. ## Kernel Object Construction Currently, it is useless to construct objects in kernel code. For example: ```python class Foo: def __init__(self): self.a = 1 def foo(self): print(self.a) class TestListRPC(EnvExperiment): def build(self): self.setattr_device("core") @kernel def run(self): foo = Foo() foo.foo() ``` would yield the following error: ``` error: type <instance artiq_run_model.Foo { __objectid__: numpy.int32 }> does not have an attribute 'foo' foo.foo() ~~~ ^^^ ``` If we just consider how we can implement this, it looks weird. The object would be stored within the device, with no host representation. What should we do when we call an RPC method? Should we create the object in the host, and somehow identify it with an ID so the data would persist across multiple RPCs? Or should we create the object in the host, and write-back its attributes to the device after the RPC is finished? What about undeclared attributes? # Solutions ## Treat as a Feature Accept it. This is not a bug, but a feature... ## More Synchronization Synchronize attributes before and after every RPCs. There are two problems here: 1. Performance, synchronization takes time, especially when some objects have a lot of attributes or a lot of data in attributes. 2. Undeclared attributes. Maybe we could just ignore that and say that this is not supported. ## Prohibit Host Functions Prohibit host functions on kernel classes, except for the constructor. For classes that are constructed within kernel functions, the constructor can be marked as a kernel function to avoid RPC calls. The kernel class instance in the host would be an object without attributes, the host can only call its methods. This sounds radical, but I think most of the code is doing something similar. There are use-cases that treat attributes as kernel invariants, but I think those can be refactored into the following form: ```python class SomeDriver: def __init__(self, *params): self.kernel = SomeDriverKernel(*params) # copy attributes... def some_function(self, *params): # if this calls a kernel function, call a kernel function self.kernel.some_function(*params) # otherwise, just run a normal host function ``` Basically, copy its parameters to avoid accessing the inner kernel instance attributes. Duplicated logic can be refactored into standalone portable functions, without access to the class instance.
Author
Collaborator

For example, after refactoring for solution 3, the TTLClockGen would look like this:

@portable
def frequency_to_ftw(frequency, acc_width, coarse_ref_period):
    """Returns the frequency tuning word corresponding to the given
    frequency.
    """
    return round(2**acc_width*frequency*coarse_ref_period)

@portable
    def ftw_to_frequency(ftw, acc_width, coarse_ref_period):
        """Returns the frequency corresponding to the given frequency tuning
        word.
        """
        return ftw/coarse_ref_period/2**acc_width

class TTLClockGenKernel:
    def __init__(self, dmgr, channel, acc_width=24, core_device="core"):
        self.core = dmgr.get(core_device)
        self.channel = channel
        self.target = channel << 8

        self.acc_width = numpy.int64(acc_width)

    @kernel
    def frequency_to_ftw(self, frequency):
        return frequency_to_ftw(frequency, self.acc_width, self.core.coarse_ref_period)

    @kernel
    def ftw_to_frequency(self, ftw):
        return ftw_to_frequency(ftw, self.acc_width, self.core.coarse_ref_period)

    # other kernel functions...
        
class TTLClockGen:
    """RTIO TTL clock generator driver.
    This should be used with TTL channels that have a clock generator
    built into the gateware (not compatible with regular TTL channels).
    The time cursor is not modified by any function in this class.
    :param channel: channel number
    :param acc_width: accumulator width in bits
    """
    def __init__(self, dmgr, channel, acc_width=24, core_device="core"):
        self.kernel = TTLClockGenKernel(dmgr, channel, acc_width, core_device)
    	self.acc_width = acc_width
    
    def frequency_to_ftw(self, frequency):
        # somehow get the coarse_ref_period without calling kernels...
        return frequency_to_ftw(frequency, self.acc_width, coarse_ref_period)

    def ftw_to_frequency(self, ftw):
        return ftw_to_frequency(ftw, self.acc_width, coarse_ref_period)
        
    # others are just a wrapper
For example, after refactoring for solution 3, the [`TTLClockGen`](https://github.com/m-labs/artiq/blob/8148fdb8a7c3ae9325e7392562bd7c89f515ec88/artiq/coredevice/ttl.py#L448-L510) would look like this: ```python @portable def frequency_to_ftw(frequency, acc_width, coarse_ref_period): """Returns the frequency tuning word corresponding to the given frequency. """ return round(2**acc_width*frequency*coarse_ref_period) @portable def ftw_to_frequency(ftw, acc_width, coarse_ref_period): """Returns the frequency corresponding to the given frequency tuning word. """ return ftw/coarse_ref_period/2**acc_width class TTLClockGenKernel: def __init__(self, dmgr, channel, acc_width=24, core_device="core"): self.core = dmgr.get(core_device) self.channel = channel self.target = channel << 8 self.acc_width = numpy.int64(acc_width) @kernel def frequency_to_ftw(self, frequency): return frequency_to_ftw(frequency, self.acc_width, self.core.coarse_ref_period) @kernel def ftw_to_frequency(self, ftw): return ftw_to_frequency(ftw, self.acc_width, self.core.coarse_ref_period) # other kernel functions... class TTLClockGen: """RTIO TTL clock generator driver. This should be used with TTL channels that have a clock generator built into the gateware (not compatible with regular TTL channels). The time cursor is not modified by any function in this class. :param channel: channel number :param acc_width: accumulator width in bits """ def __init__(self, dmgr, channel, acc_width=24, core_device="core"): self.kernel = TTLClockGenKernel(dmgr, channel, acc_width, core_device) self.acc_width = acc_width def frequency_to_ftw(self, frequency): # somehow get the coarse_ref_period without calling kernels... return frequency_to_ftw(frequency, self.acc_width, coarse_ref_period) def ftw_to_frequency(self, ftw): return ftw_to_frequency(ftw, self.acc_width, coarse_ref_period) # others are just a wrapper ```
Collaborator

Let me share my thoughts.

Treat it as a feature

We are aware of the limitations described above and we do not run into any problems. Most of the object attributes we use in kernels are invariant anyway with just a few exceptions. I am fine keeping it the way it is.

More sync

That would work for us but I am not sure if the performance penalty is reasonable. Especially async RPC functions are expected to have reasonable throughput. Sync RPC functions are expected to be slow, so extra synchronization is not an issue for those functions in my opinion.

Prohibit host functions

At this moment, this seems unreasonable to me. We do combine host and kernel functions in a single class often and not all situations can be conveniently solved as the given example above. See for example how we use @portable and kernel invariants in this code https://gitlab.com/duke-artiq/dax/-/blob/master/dax/base/scan.py#L491 or in this example where we combine kernels and non-kernel functions in one class https://gitlab.com/duke-artiq/dax/-/blob/master/dax/modules/led.py .


Just to note, I work at Duke and we are daily users of ARTIQ. If there is any need to gain more insight in how we use ARTIQ, let me know!

Let me share my thoughts. **Treat it as a feature** We are aware of the limitations described above and we do not run into any problems. Most of the object attributes we use in kernels are invariant anyway with just a few exceptions. I am fine keeping it the way it is. **More sync** That would work for us but I am not sure if the performance penalty is reasonable. Especially async RPC functions are expected to have reasonable throughput. Sync RPC functions are expected to be slow, so extra synchronization is not an issue for those functions in my opinion. **Prohibit host functions** At this moment, this seems unreasonable to me. We do combine host and kernel functions in a single class often and not all situations can be conveniently solved as the given example above. See for example how we use `@portable` and kernel invariants in this code https://gitlab.com/duke-artiq/dax/-/blob/master/dax/base/scan.py#L491 or in this example where we combine kernels and non-kernel functions in one class https://gitlab.com/duke-artiq/dax/-/blob/master/dax/modules/led.py . --- Just to note, I work at Duke and we are daily users of ARTIQ. If there is any need to gain more insight in how we use ARTIQ, let me know!
Owner

Here's another idea:

@kernel
class Example:
  y: int
  z: Immutable(int)

  def __init__(self):
    self.x = 1
    self.y = 2
    self.z = 3

  def correct1(self):
    self.x += 1
    return self.x + self.z
   
  @kernel
  def correct2(self):
    self.y += 1
    self.quux(self.z)

  def wrong1(self):
    print(self.y)
    
  def wrong2(self):
    self.z = 4
  
  @kernel
  def wrong3(self):
    self.x += 1

  1. x is a regular Python attribute, and it is host-only. Attempting to access it from a kernel results in a compilation error.
  2. y is a kernel-only attribute. Attempting to access it on the host outside of __init__ results in a runtime error.
  3. z can be read from both the kernel and the host. Attempting to write it from a kernel results in a compilation error, and from the host in a runtime error. It can only be set during __init__.

The @kernel decorator on Example marks the class for compilation, and also modifies the class to enforce the restrictions of (2) and (3) in the Python interpreter. (NB: Using a decorator instead of inheritance makes it easier to intercept the __init__ call)

Interior mutability will create some challenges, e.g. blocking attempts to modify the contents of Immutable(list[...]) on the host. Wrapping the list type might make things slower and/or cause compatibility problems.

Here's another idea: ```python @kernel class Example: y: int z: Immutable(int) def __init__(self): self.x = 1 self.y = 2 self.z = 3 def correct1(self): self.x += 1 return self.x + self.z @kernel def correct2(self): self.y += 1 self.quux(self.z) def wrong1(self): print(self.y) def wrong2(self): self.z = 4 @kernel def wrong3(self): self.x += 1 ``` 1. ``x`` is a regular Python attribute, and it is host-only. Attempting to access it from a kernel results in a compilation error. 2. ``y`` is a kernel-only attribute. Attempting to access it on the host outside of ``__init__`` results in a runtime error. 3. ``z`` can be read from both the kernel and the host. Attempting to write it from a kernel results in a compilation error, and from the host in a runtime error. It can only be set during ``__init__``. The ``@kernel`` decorator on ``Example`` marks the class for compilation, and also modifies the class to enforce the restrictions of (2) and (3) in the Python interpreter. (NB: Using a decorator instead of inheritance makes it easier to intercept the ``__init__`` call) Interior mutability will create some challenges, e.g. blocking attempts to modify the contents of ``Immutable(list[...])`` on the host. Wrapping the list type might make things slower and/or cause compatibility problems.
Owner

We could perhaps forbid Immutable(list[...]) and only allow Immutable(tuple[...]) instead (and of course, it would have to recursively typecheck, and the tuple could not contain lists).

We could perhaps forbid ``Immutable(list[...])`` and only allow ``Immutable(tuple[...])`` instead (and of course, it would have to recursively typecheck, and the tuple could not contain lists).
Owner

And for Immutable(ndarray(...)), Numpy supports read-only arrays:
https://stackoverflow.com/questions/5541324/immutable-numpy-array
Not perfect but should be ok in practice.

And for ``Immutable(ndarray(...))``, Numpy supports read-only arrays: https://stackoverflow.com/questions/5541324/immutable-numpy-array Not perfect but should be ok in practice.
Collaborator

The latest proposal definitely sounds more reasonable, though it still blocks one of our important use cases. We often modify/configure kernel invariant variables before entering a kernel. This allows us to dynamically set up data or even functions that will be compiled into a kernel at runtime.

Maybe it could be an option to give more freedom to functions that are decorated as @host_only, for example that host only functions are able to set "immutable" variables. That would still resolve the potentially confusing behavior regarding attribute synchronization in RPC functions but allows us to still modify variables used in kernels in functions decorated as host only.

So for example:

@kernel
class Example:
  y: int
  z: KernelInvariant(int)  # Formerly "Immutable"

  def __init__(self):
    self.x = 1
    self.y = 2
    self.z = 3

  def correct1(self):
    self.x += 1
    return self.x + self.z
   
  @kernel
  def correct2(self):
    self.y += 1
    self.quux(self.z)
    
  @host_only
  def correct3(self):
    self.z = 4

  def wrong1(self):
    print(self.y)
    
  def wrong2(self):
    self.z = 4
  
  @kernel
  def wrong3(self):
    self.x += 1
    
  @kernel
  def wrong4(self):
    self.z = 4
The latest proposal definitely sounds more reasonable, though it still blocks one of our important use cases. We often [modify/configure kernel invariant variables before entering a kernel](https://gitlab.com/duke-artiq/dax/-/blob/master/dax/base/scan.py#L421). This allows us to dynamically set up data or even functions that will be compiled into a kernel at runtime. Maybe it could be an option to give more freedom to functions that are decorated as `@host_only`, for example that host only functions are able to set "immutable" variables. That would still resolve the potentially confusing behavior regarding attribute synchronization in RPC functions but allows us to still modify variables used in kernels in functions decorated as host only. So for example: ```python @kernel class Example: y: int z: KernelInvariant(int) # Formerly "Immutable" def __init__(self): self.x = 1 self.y = 2 self.z = 3 def correct1(self): self.x += 1 return self.x + self.z @kernel def correct2(self): self.y += 1 self.quux(self.z) @host_only def correct3(self): self.z = 4 def wrong1(self): print(self.y) def wrong2(self): self.z = 4 @kernel def wrong3(self): self.x += 1 @kernel def wrong4(self): self.z = 4 ```
Author
Collaborator

The latest proposal definitely sounds more reasonable, though it still blocks one of our important use cases. We often modify/configure kernel invariant variables before entering a kernel. This allows us to dynamically set up data or even functions that will be compiled into a kernel at runtime.

Maybe it could be an option to give more freedom to functions that are decorated as @host_only, for example that host only functions are able to set "immutable" variables. That would still resolve the potentially confusing behavior regarding attribute synchronization in RPC functions but allows us to still modify variables used in kernels in functions decorated as host only.

So for example:

@kernel
class Example:
  y: int
  z: KernelInvariant(int)  # Formerly "Immutable"

  def __init__(self):
    self.x = 1
    self.y = 2
    self.z = 3

  def correct1(self):
    self.x += 1
    return self.x + self.z
   
  @kernel
  def correct2(self):
    self.y += 1
    self.quux(self.z)
    
  @host_only
  def correct3(self):
    self.z = 4

  def wrong1(self):
    print(self.y)
    
  def wrong2(self):
    self.z = 4
  
  @kernel
  def wrong3(self):
    self.x += 1
    
  @kernel
  def wrong4(self):
    self.z = 4

However, if the host function is called while the kernel is executing, e.g. via RPC, the invariant would be modified without the kernel noticing it.

> The latest proposal definitely sounds more reasonable, though it still blocks one of our important use cases. We often [modify/configure kernel invariant variables before entering a kernel](https://gitlab.com/duke-artiq/dax/-/blob/master/dax/base/scan.py#L421). This allows us to dynamically set up data or even functions that will be compiled into a kernel at runtime. > > Maybe it could be an option to give more freedom to functions that are decorated as `@host_only`, for example that host only functions are able to set "immutable" variables. That would still resolve the potentially confusing behavior regarding attribute synchronization in RPC functions but allows us to still modify variables used in kernels in functions decorated as host only. > > So for example: > ```python > @kernel > class Example: > y: int > z: KernelInvariant(int) # Formerly "Immutable" > > def __init__(self): > self.x = 1 > self.y = 2 > self.z = 3 > > def correct1(self): > self.x += 1 > return self.x + self.z > > @kernel > def correct2(self): > self.y += 1 > self.quux(self.z) > > @host_only > def correct3(self): > self.z = 4 > > def wrong1(self): > print(self.y) > > def wrong2(self): > self.z = 4 > > @kernel > def wrong3(self): > self.x += 1 > > @kernel > def wrong4(self): > self.z = 4 > ``` However, if the host function is called while the kernel is executing, e.g. via RPC, the invariant would be modified without the kernel noticing it.
Collaborator

@pca006132 that is a true point I did oversee. Would it be reasonable to let the @host_only decorator confirm at runtime if the core is running a kernel? Keeping a flag in the core device driver should not be hard. Functionality wise, I guess it is not too far away from the initial proposal of @sb10q where writing to an Immutable() variable from the host result in a RuntimeError.

And I would also like to note again that at least for Duke, the current behavior is totally fine.

@pca006132 that is a true point I did oversee. Would it be reasonable to let the `@host_only` decorator confirm at runtime if the core is running a kernel? Keeping a flag in the core device driver should not be hard. Functionality wise, I guess it is not too far away from the initial proposal of @sb10q where writing to an `Immutable()` variable from the host result in a `RuntimeError`. And I would also like to note again that at least for Duke, the current behavior is totally fine.
Owner

However, if the host function is called while the kernel is executing, e.g. via RPC, the invariant would be modified without the kernel noticing it.

We can disallow RPC of host functions unless they are explicity decorated @rpc, and disallow modifying Immutable attributes in @rpc methods.

> However, if the host function is called while the kernel is executing, e.g. via RPC, the invariant would be modified without the kernel noticing it. We can disallow RPC of host functions unless they are explicity decorated ``@rpc``, and disallow modifying ``Immutable`` attributes in ``@rpc`` methods.
Collaborator

I like the idea of explicitly marking each @rpc function, making all undecorated functions "host only" by default.

I like the idea of explicitly marking each `@rpc` function, making all undecorated functions "host only" by default.
Author
Collaborator

@pca006132 that is a true point I did oversee. Would it be reasonable to let the @host_only decorator confirm at runtime if the core is running a kernel? Keeping a flag in the core device driver should not be hard. Functionality wise, I guess it is not too far away from the initial proposal of @sb10q where writing to an Immutable() variable from the host result in a RuntimeError.

Yes, actually after thinking for a bit, it seems that the user can modify the instance variables without calling host methods, just RPC to the host, call a public function or something which access the object and modify its field.

I think maybe we should explicitly mark kernel and RPC functions, let the remaining functions to be host only, and warn the user about modifying instance variables in the host while the kernel is executing...

For Python, I think it would be hard to enforce mutability constraints, especially when we want the instance variables to be mutable when the kernel is not executed, but immutable when the kernel is executed. Maybe check the modification in RPC functions and warn them, but we can't really do much better than this.

> @pca006132 that is a true point I did oversee. Would it be reasonable to let the `@host_only` decorator confirm at runtime if the core is running a kernel? Keeping a flag in the core device driver should not be hard. Functionality wise, I guess it is not too far away from the initial proposal of @sb10q where writing to an `Immutable()` variable from the host result in a `RuntimeError`. Yes, actually after thinking for a bit, it seems that the user can modify the instance variables without calling host methods, just RPC to the host, call a public function or something which access the object and modify its field. I think maybe we should explicitly mark kernel and RPC functions, let the remaining functions to be host only, and warn the user about modifying instance variables in the host while the kernel is executing... For Python, I think it would be hard to enforce mutability constraints, especially when we want the instance variables to be mutable when the kernel is not executed, but immutable when the kernel is executed. Maybe check the modification in RPC functions and warn them, but we can't really do much better than this.
Collaborator

I agree that it will be interesting to enforce mutability constraints on the host. Probably with the @kernel class decorator and overriding __setattr__() and __getattr__() I guess.

Let me maybe summarize the instance variable types and see where we are.

  1. host-only variables, not accessed in kernels
    • do not have the type annotation as part of the class definition, making them inaccessible in kernels
  2. kernel-only variables
    • do have the type annotation as part of the class definition. host can only set the value in the __init__() function.
  3. kernel-invariant variables, but mutable on the host
    • have a special type annotation (KernelInvariant or Immutable) and also special semantics. no restrictions on host access, but invariant in the kernels, and therefore there is no synchronization during or after kernel execution.
  4. host-invariant variables, but mutable in the kernel.
    • basically an extension of kernel-only variables with read-only access on the host. not sure if any examples exist and would probably cause synchronization concerns.
  5. variables both mutable in kernels and on the host
    • these are the ones that probably sparked this discussion. to be honest, I am not sure how often these cases happen and the proposals in this thread eliminate this option.

So I guess most concerns are around instance variables that can be modified in kernels and can be accessed from the host. If we eliminate 4 and 5, then only 3 needs some additional documentation.

@kernel
class Example:
  y: int
  z: KernelInvariant(int)

  def __init__(self):
    self.x = 1
    self.y = 2
    self.z = 3

  def correct1(self):
    self.x += 1
    self.z += 1
    return self.x + self.z
   
  @kernel
  def correct2(self):
    self.y += 1
    self.quux(self.z)
    
  @rpc
  def correct3(self) -> int:
    self.z -= 1
    return self.z

  def wrong1(self):
    print(self.y)
    
  @kernel
  def wrong2(self):
    self.z = 4
  
  @kernel
  def wrong3(self):
    self.x += 1
I agree that it will be interesting to enforce mutability constraints on the host. Probably with the `@kernel` class decorator and overriding `__setattr__()` and `__getattr__()` I guess. Let me maybe summarize the instance variable types and see where we are. 1. host-only variables, not accessed in kernels * do not have the type annotation as part of the class definition, making them inaccessible in kernels 1. [kernel-only variables](https://github.com/m-labs/artiq/blob/release-6/artiq/coredevice/urukul.py#L195) * do have the type annotation as part of the class definition. host can only set the value in the `__init__()` function. 1. [kernel-invariant variables, but mutable on the host](https://gitlab.com/duke-artiq/dax/-/blob/master/dax/modules/hist_context.py#L137) * have a special type annotation (`KernelInvariant` or `Immutable`) and also special semantics. no restrictions on host access, but invariant in the kernels, and therefore there is no synchronization during or after kernel execution. 1. host-invariant variables, but mutable in the kernel. * basically an extension of kernel-only variables with read-only access on the host. not sure if any examples exist and would probably cause synchronization concerns. 1. variables both mutable in kernels and on the host * these are the ones that probably sparked this discussion. to be honest, I am not sure how often these cases happen and the proposals in this thread eliminate this option. So I guess most concerns are around instance variables that can be modified in kernels and can be accessed from the host. If we eliminate 4 and 5, then only 3 needs some additional documentation. ```python @kernel class Example: y: int z: KernelInvariant(int) def __init__(self): self.x = 1 self.y = 2 self.z = 3 def correct1(self): self.x += 1 self.z += 1 return self.x + self.z @kernel def correct2(self): self.y += 1 self.quux(self.z) @rpc def correct3(self) -> int: self.z -= 1 return self.z def wrong1(self): print(self.y) @kernel def wrong2(self): self.z = 4 @kernel def wrong3(self): self.x += 1 ```
Author
Collaborator

I agree that it will be interesting to enforce mutability constraints on the host. Probably with the @kernel class decorator and overriding __setattr__() and __getattr__() I guess.

Let me maybe summarize the instance variable types and see where we are.

  1. host-only variables, not accessed in kernels
    • do not have the type annotation as part of the class definition, making them inaccessible in kernels
  2. kernel-only variables
    • do have the type annotation as part of the class definition. host can only set the value in the __init__() function.
  3. kernel-invariant variables, but mutable on the host
    • have a special type annotation (KernelInvariant or Immutable) and also special semantics. no restrictions on host access, but invariant in the kernels, and therefore there is no synchronization during or after kernel execution.
  4. host-invariant variables, but mutable in the kernel.
    • basically an extension of kernel-only variables with read-only access on the host. not sure if any examples exist and would probably cause synchronization concerns.
  5. variables both mutable in kernels and on the host
    • these are the ones that probably sparked this discussion. to be honest, I am not sure how often these cases happen and the proposals in this thread eliminate this option.

So I guess most concerns are around instance variables that can be modified in kernels and can be accessed from the host. If we eliminate 4 and 5, then only 3 needs some additional documentation.

@kernel
class Example:
  y: int
  z: KernelInvariant(int)

  def __init__(self):
    self.x = 1
    self.y = 2
    self.z = 3

  def correct1(self):
    self.x += 1
    self.z += 1
    return self.x + self.z
   
  @kernel
  def correct2(self):
    self.y += 1
    self.quux(self.z)
    
  @rpc
  def correct3(self) -> int:
    self.z -= 1
    return self.z

  def wrong1(self):
    print(self.y)
    
  @kernel
  def wrong2(self):
    self.z = 4
  
  @kernel
  def wrong3(self):
    self.x += 1

Yes. For 1 and 3, it would be simple to enforce in the kernel.
If we allow for more complex objects, we may also have to document case 2, as the host may be able to indirectly access the instance variable if it is an object.

> I agree that it will be interesting to enforce mutability constraints on the host. Probably with the `@kernel` class decorator and overriding `__setattr__()` and `__getattr__()` I guess. > > Let me maybe summarize the instance variable types and see where we are. > > 1. host-only variables, not accessed in kernels > * do not have the type annotation as part of the class definition, making them inaccessible in kernels > 1. [kernel-only variables](https://github.com/m-labs/artiq/blob/release-6/artiq/coredevice/urukul.py#L195) > * do have the type annotation as part of the class definition. host can only set the value in the `__init__()` function. > 1. [kernel-invariant variables, but mutable on the host](https://gitlab.com/duke-artiq/dax/-/blob/master/dax/modules/hist_context.py#L137) > * have a special type annotation (`KernelInvariant` or `Immutable`) and also special semantics. no restrictions on host access, but invariant in the kernels, and therefore there is no synchronization during or after kernel execution. > 1. host-invariant variables, but mutable in the kernel. > * basically an extension of kernel-only variables with read-only access on the host. not sure if any examples exist and would probably cause synchronization concerns. > 1. variables both mutable in kernels and on the host > * these are the ones that probably sparked this discussion. to be honest, I am not sure how often these cases happen and the proposals in this thread eliminate this option. > > So I guess most concerns are around instance variables that can be modified in kernels and can be accessed from the host. If we eliminate 4 and 5, then only 3 needs some additional documentation. > > ```python > @kernel > class Example: > y: int > z: KernelInvariant(int) > > def __init__(self): > self.x = 1 > self.y = 2 > self.z = 3 > > def correct1(self): > self.x += 1 > self.z += 1 > return self.x + self.z > > @kernel > def correct2(self): > self.y += 1 > self.quux(self.z) > > @rpc > def correct3(self) -> int: > self.z -= 1 > return self.z > > def wrong1(self): > print(self.y) > > @kernel > def wrong2(self): > self.z = 4 > > @kernel > def wrong3(self): > self.x += 1 > ``` Yes. For 1 and 3, it would be simple to enforce in the kernel. If we allow for more complex objects, we may also have to document case 2, as the host may be able to indirectly access the instance variable if it is an object.
Owner

For Python, I think it would be hard to enforce mutability constraints, especially when we want the instance variables to be mutable when the kernel is not executed, but immutable when the kernel is executed.

That's not much of an issue actually, in the worst case we have a global boolean variable that says if a kernel is currently executing (easy to do in the @kernel decorator implementation) and the code that blocks mutability simply stops throwing errors when a kernel isn't executing.

I could write some PoC code for this.

If we allow for more complex objects, we may also have to document case 2, as the host may be able to indirectly access the instance variable if it is an object.

That's the "interior mutability" problem I mentioned. But, those objects would also have a @kernel decorator in order to be visible at all in the kernels, so we can hack them.

> For Python, I think it would be hard to enforce mutability constraints, especially when we want the instance variables to be mutable when the kernel is not executed, but immutable when the kernel is executed. That's not much of an issue actually, in the worst case we have a global boolean variable that says if a kernel is currently executing (easy to do in the `@kernel` decorator implementation) and the code that blocks mutability simply stops throwing errors when a kernel isn't executing. I could write some PoC code for this. > If we allow for more complex objects, we may also have to document case 2, as the host may be able to indirectly access the instance variable if it is an object. That's the "interior mutability" problem I mentioned. But, those objects would also have a `@kernel` decorator in order to be visible at all in the kernels, so we can hack them.
Owner
import inspect
from functools import wraps


IN_KERNEL = False


def kernel(x):
    if inspect.isclass(x):
        if x.__setattr__ is not object.__setattr__:
            raise ValueError("custom __setattr__ is not supported in kernel classes")
        def __setattr__(obj, key, value):
            if IN_KERNEL and key == "immutable":
                raise TypeError("attempting to write to immutable variable while kernel is executing")
            object.__setattr__(obj, key, value)
        x.__setattr__ = __setattr__
        return x
    else:
        @wraps(x)
        def fn(*args, **kwargs):
            global IN_KERNEL
            IN_KERNEL = True
            x(*args, **kwargs)
            IN_KERNEL = False
        return fn


@kernel
class MutDemo:
    def __init__(self):
        self.immutable = 42

    def foo(self):
        self.immutable = 1

    @kernel
    def fail(self):
        self.foo()

demo = MutDemo()
print("foo")
demo.foo()
print("fail")
demo.fail()
```python import inspect from functools import wraps IN_KERNEL = False def kernel(x): if inspect.isclass(x): if x.__setattr__ is not object.__setattr__: raise ValueError("custom __setattr__ is not supported in kernel classes") def __setattr__(obj, key, value): if IN_KERNEL and key == "immutable": raise TypeError("attempting to write to immutable variable while kernel is executing") object.__setattr__(obj, key, value) x.__setattr__ = __setattr__ return x else: @wraps(x) def fn(*args, **kwargs): global IN_KERNEL IN_KERNEL = True x(*args, **kwargs) IN_KERNEL = False return fn @kernel class MutDemo: def __init__(self): self.immutable = 42 def foo(self): self.immutable = 1 @kernel def fail(self): self.foo() demo = MutDemo() print("foo") demo.foo() print("fail") demo.fail() ```
Author
Collaborator

Thanks for the PoC, but I think we have to recursively apply this to all its fields, and possibly consider the case for lists as well. Otherwise if we modify MutDemo it would not throw an error:

@kernel
class MutDemo:
    def __init__(self):
        self.immutable = [42]

    def foo(self):
        self.immutable[0] = 1

    @kernel
    def fail(self):
        self.foo()

But yeah, this is possible.

Thanks for the PoC, but I think we have to recursively apply this to all its fields, and possibly consider the case for lists as well. Otherwise if we modify `MutDemo` it would not throw an error: ```python @kernel class MutDemo: def __init__(self): self.immutable = [42] def foo(self): self.immutable[0] = 1 @kernel def fail(self): self.foo() ``` But yeah, this is possible.
Owner

I suggested above to disallow Immutable(list[...]) and only allow Immutable(tuple(...)).
Then the other objects are user-defined objects, and decorated by @kernel and therefore hackable.

I suggested above to disallow ``Immutable(list[...])`` and only allow ``Immutable(tuple(...))``. Then the other objects are user-defined objects, and decorated by ``@kernel`` and therefore hackable.
Collaborator

Kernel invariant / immutable lists is actually something that is used for iterating over items in kernels. Often with a lists of primitives, objects (e.g. devices), or functions. These could be converted to numpy arrays if those are still allowed to be kernel invariant / immutable. See example below.

For user-defined objects marked kernel invariant / immutable, I would expect the reference to the object to be immutable in kernels, but attributes of the object itself do not have to be immutable as this will be defined in the class definition of that object, which is also decorated with @kernel. This is also the current behavior with kernel invariant user-defined objects.

On a side note, typing is normally done with [] instead of (). I assume that is also the syntax expected for typing any ARTIQ related stuff.

from artiq.experiment import *
import numpy as np
from typing import Callable

@kernel
class Foo(EnvExperiment):
    devices: KernelInvariant[np.ndarray[object]]  # Not sure how you expect this to be typed
    functions: KernelInvariant[np.ndarray[Callable[[], None]]]

    def __init__(self):
        devices = [self.get_device(f'ttl{i}') for i in range(4)]
        self.devices = np.asarray(devices)
        self.functions = np.asarray([self.foo, self.bar, self.bar, self.foo])
        
    @kernel
    def run(self) -> None:
        for d in self.devices:  # Iterate over an array of devices
            d.pulse(10*us)
        for fn in self.functions:  # Iterate over an array of functions
            fn()
            
    @kernel
    def foo(self) -> None:
        self.devices[0].pulse(10*us)
        
    @kernel
    def bar(self) -> None:
        self.devices[3].pulse(20*us)
Kernel invariant / immutable lists is actually something that is used for iterating over items in kernels. Often with a lists of primitives, objects (e.g. devices), or functions. These could be converted to numpy arrays if those are still allowed to be kernel invariant / immutable. See example below. For user-defined objects marked kernel invariant / immutable, I would expect the reference to the object to be immutable in kernels, but attributes of the object itself do not have to be immutable as this will be defined in the class definition of that object, which is also decorated with `@kernel`. This is also the current behavior with kernel invariant user-defined objects. On a side note, typing is normally done with `[]` instead of `()`. I assume that is also the syntax expected for typing any ARTIQ related stuff. ```python from artiq.experiment import * import numpy as np from typing import Callable @kernel class Foo(EnvExperiment): devices: KernelInvariant[np.ndarray[object]] # Not sure how you expect this to be typed functions: KernelInvariant[np.ndarray[Callable[[], None]]] def __init__(self): devices = [self.get_device(f'ttl{i}') for i in range(4)] self.devices = np.asarray(devices) self.functions = np.asarray([self.foo, self.bar, self.bar, self.foo]) @kernel def run(self) -> None: for d in self.devices: # Iterate over an array of devices d.pulse(10*us) for fn in self.functions: # Iterate over an array of functions fn() @kernel def foo(self) -> None: self.devices[0].pulse(10*us) @kernel def bar(self) -> None: self.devices[3].pulse(20*us) ```
Owner

Kernel invariant / immutable lists is actually something that is used for iterating over items in kernels

You can iterate over a tuple.

e.g.

self.functions = (self.foo, self.bar, self.bar, self.foo)

...

        for fn in self.functions:  # Iterate over a tuple of functions
            fn()

> Kernel invariant / immutable lists is actually something that is used for iterating over items in kernels You can iterate over a tuple. e.g. ```python self.functions = (self.foo, self.bar, self.bar, self.foo) ... for fn in self.functions: # Iterate over a tuple of functions fn() ```
Collaborator

Treat it as a feature

We are aware of the limitations described above and we do not run into any problems. Most of the object attributes we use in kernels are invariant anyway with just a few exceptions. I am fine keeping it the way it is.

In my experience, this is a major footgun in the current artiq-python language. Every new user hits attriubte synchronisation bugs. Clear documentation with examples (and an explanation of why the current behaviour is the way it is) would go a long way to resolving this, but I am also interested in exploring ways we can do better.

NB the current behaviour is made worse by the various bugs around returning lists from RPCs.

More sync

That would work for us but I am not sure if the performance penalty is reasonable. Especially async RPC functions are expected to have reasonable throughput. Sync RPC functions are expected to be slow, so extra synchronization is not an issue for those functions in my opinion.

If I've understood this correctly, it feels potentially problematic (e.g. do RPCs for classes which store a lot of data internally now become really slow?)

> Treat it as a feature > > We are aware of the limitations described above and we do not run into any problems. Most of the object attributes we use in kernels are invariant anyway with just a few exceptions. I am fine keeping it the way it is. In my experience, this is a major footgun in the current artiq-python language. Every new user hits attriubte synchronisation bugs. Clear documentation with examples (and an explanation of why the current behaviour is the way it is) would go a long way to resolving this, but I am also interested in exploring ways we can do better. NB the current behaviour is made worse by the various bugs around returning lists from RPCs. > More sync > > That would work for us but I am not sure if the performance penalty is reasonable. Especially async RPC functions are expected to have reasonable throughput. Sync RPC functions are expected to be slow, so extra synchronization is not an issue for those functions in my opinion. If I've understood this correctly, it feels potentially problematic (e.g. do RPCs for classes which store a lot of data internally now become really slow?)
Owner

@hartytp please read the rest of the discussion. Are there any issues with my proposal (and subsequent refinements): #5 (comment)

@hartytp please read the rest of the discussion. Are there any issues with my proposal (and subsequent refinements): https://git.m-labs.hk/M-Labs/nac3-spec/issues/5#issuecomment-2003
Sign in to join this conversation.
No Label
No Milestone
No Assignees
4 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: M-Labs/nac3-spec#5
No description provided.