{"article":{"slug":"objects-attributes-maps-oh-my","title":"Objects, attributes, maps, oh my!","subtitle":null,"summary":"Max Bernstein walks through how dynamic language VMs move from hash-table objects to hidden classes (shapes or maps) and inline caches, with Python sketches and notes on how ZJIT handles polymorphic shapes in Ruby.","content_type":"blog_post","language":"en","canonical_url":"https://bernsteinbear.com/blog/object-shapes/","author":{"name":"Max Bernstein","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Max Bernstein","url":"https://bernsteinbear.com/","listing_slug":null,"listing":null},"topics":[{"name":"Programming languages","slug":"programming-languages","url":"https://listedarticles.com/topics/programming-languages"},{"name":"Compilers","slug":"compilers","url":"https://listedarticles.com/topics/compilers"},{"name":"Performance","slug":"performance","url":"https://listedarticles.com/topics/performance"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":3019,"reading_minutes":13,"published_at":"2026-10-21T00:00:00.000Z","added_at":"2026-10-22T20:12:09.664Z","updated_at":"2026-10-22T20:12:09.664Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/objects-attributes-maps-oh-my","markdown_url":"https://listedarticles.com/articles/objects-attributes-maps-oh-my.md","example":false,"citation":"Max Bernstein, Max Bernstein. \"Objects, attributes, maps, oh my!.\" 21 Oct 2026. https://bernsteinbear.com/blog/object-shapes/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://bernsteinbear.com/blog/object-shapes/"},"body_markdown":"# Objects, attributes, maps, oh my!\n\nOr, *IC UC we all C for ICs*.\n\nIn many dynamically typed programming languages, objects look like a big ol’\nmapping of names to values. The languages encourage this flexibility. Consider\nfor example Python:\n\n```\nclass C:\n    def __init__(self):\n        self.a = 1\n        self.b = 2\n```\n\nOr Ruby:\n\n```\nclass C\n  def initialize\n    @a = 1\n    @b = 2\n  end\nend\n```\n\nOr JavaScript[1](#fn:js-synonyms):\n\n```\nlet obj = {};\nobj.a = 1;\nobj.b = 2;\n```\n\nThis might encourage the VM implementor to model object attributes using a hash\ntable. Hash tables, after all, are very good at mapping names to values.\n\nHere is, by way of example, a Python sketch of what this object model looks\nlike. Because it is a sketch, it uses `write_attr` and `read_attr` instead of\nthe much more common syntax that your programming language comes with.\n\n```\nMISSING = object()\n\nclass Instance:\n    def __init__(self):\n        # TODO: the rest of your object system\n        self.cls = ...\n        self._fields = {}\n\n    def read_attr(self, fieldname):\n        return self._fields.get(fieldname, MISSING)\n\n    def write_attr(self, fieldname, value):\n        self._fields[fieldname] = value\n\nobj = Instance()\nobj.write_attr(\"a\", 1)\nobj.write_attr(\"b\", 2)\nprint(obj.read_attr(\"a\") + obj.read_attr(\"b\"))\n```\n\nThis has its benefits:\n\n- it is straightforward to understand\n- … uh, well, maybe that’s it.\n\nIt also has its drawbacks:\n\n- it uses a hash table for a very common operation: while this is technically\n  O(1), hashing and probing are very slow\n- it has an indirection: jumping from an object to its separate hash table\n  field storage requires a lot of dependent memory loads. object attributes are\n  accessed frequently and should therefore be stored in-line on the object\n  instead of another memory access (and potentially cache miss) away\n\nThis is a pretty good case where the hash table doesn't have its own internal\nfragmentation.\n\nThis means that literally every operation `a.b` is doing hundreds or thousands\nof CPU instructions, including multiple dependent memory loads. This is *not\ngood* for performance.\n\nSo runtime implementors tend to implement this thing called maps, or layouts,\nor shapes, or hidden classes, or structures, or probably a secret sixth name I\ndon’t know about, which optimizes reading, writing, and storage of attributes.\n\nBut what does this shape code look like? How does it actually help performance?\nA lot of big production runtimes have pretty hairy code with domain-specific\nedge cases so they may not help get a handle on the basics. And a lot of blog\nposts, while excellent high-level overviews, are lacking in implementation. So\nhere we go once more, blogging where everyone else has already blogged before.\nWith any luck, you should be able to write a page of code and hold the whole\nthing in your head at once.\n\n## 500 lines or less\n\nI decided to once more give CF Bolz-Tereick’s [A Simple Object\nModel](https://aosabook.org/en/500L/a-simple-object-model.html) the thorough\nread it deserves. In the past, I tried a couple times and bounced off of it\nbecause it models Python with reasonable fidelity and the Python object model\nis *really hairy*.\n\nBut did I read it thoroughly this time? Well, no. This time I allowed myself to\nskim the class- and method-related parts and instead focus on the instance\nattribute and map parts. Inside CF’s post is the core of a very clean map\nsystem and I wanted to experiment with that, so I extracted it out.\n\nCF’s post starts off very similar to our own sketch, modeling objects as\nunordered maps of attributes. It also fills in some of the TODO stuff I left\nout earlier around classes. I’ll copy my reduced implementation of it here so\nyou can take a gander:\n\n```\n# The base object model code:\n\nclass Base:\n    def __init__(self, cls, fields):\n        self.cls = cls\n        self._fields = fields\n\n    def read_attr(self, fieldname):\n        return self._read_dict(fieldname)\n\n    def write_attr(self, fieldname, value):\n        return self._write_dict(fieldname, value)\n\n    def _read_dict(self, fieldname):\n        return self._fields.get(fieldname, MISSING)\n\n    def _write_dict(self, fieldname, value):\n        self._fields[fieldname] = value\n\nMISSING = object()\n\nclass Instance(Base):\n    def __init__(self, cls: \"Class\"):\n        super().__init__(cls, {})\n\nclass Class(Base):\n    def __init__(self, name, base_class, fields, metaclass):\n        super().__init__(metaclass, fields)\n        self.name = name\n        self.base_class = base_class\n\nOBJECT = Class(name=\"object\", base_class=None, fields={}, metaclass=None)\nTYPE = Class(name=\"type\", base_class=OBJECT, fields={}, metaclass=None)\nTYPE.cls = TYPE\nOBJECT.cls = TYPE\n```\n\nWhich you can use like this (test code also taken from CF’s post):\n\n```\nA = Class(name=\"A\", base_class=OBJECT, fields={}, metaclass=TYPE)\nobj = Instance(A)\nobj.write_attr(\"a\", 1)\nassert obj.read_attr(\"a\") == 1\n\nobj.write_attr(\"b\", 5)\nassert obj.read_attr(\"a\") == 1\nassert obj.read_attr(\"b\") == 5\n\nobj.write_attr(\"a\", 2)\nassert obj.read_attr(\"a\") == 2\nassert obj.read_attr(\"b\") == 5\n```\n\nThere’s a bit of delegation going on in the `Base` class because the post\nalready anticipates what we’re about to do: swap out the `Instance`\nimplementation for `_read_dict` and `_write_dict` for better performance.\n\n## Cartography\n\nOK, let’s add on this “map” data structure. The purpose of the map is to do\nthree things:\n\n1. Get out of the business of dealing with strings and hashing in the fast\n   path. We would like to instead do operations on small integers.\n2. Remove the indirection. We would like to store the attributes in-line in the\n   object instead of elsewhere in memory.\n3. Be/have a stable identifier that describes a particular object layout.\n\n**For the former**, we can think of an object’s map field as an (database) index or\na write-through cache. It is responsible for reflecting on the layout of a\ngiven object:\n\nThis model is *mostly* there...\n\nYou can ask the map questions like “what attributes does this object have?” and\n“where is attribute `a` relative to the start of the object?”\n\nJust looking at the diagram, it may seem silly: after all, we have exchanged\none hash table for another. There’s one big difference, though, which is that\nin the shape model, we don’t *have* to always use the hash table. If we\nremember the offset since the last time we looked it up in the shape, we can\nre-use the offset. This mostly appears in an interpreter feature called an\ninline cache.\n\nThis brings us to **the second bit**: if we have the offset into the object, we\ncan read right off of it. No need to go do a second load from another region of\nmemory. We have the object in hand and hopefully already in the cache.\n\n…At least, until the map *changes*. Then we can’t re-use the offset. Remember, these languages encourage this\nflexibility. An object could gain or lose an attribute at any time. What then?\nIdeally we have a very fast check that can tell us if the object’s in-memory\nlayout changed.\n\nThis brings us to **the third bit**. We need to be able to rely on the map to\nbe immutable, up-to-date, and a stable identifier for a given in-memory layout.\nThis way we can do a very quick check (“is the map the same as last read?”)\nbefore a load.\n\nThis means that when we add a new attribute to an object, we need to give it a\nnew map. Maps are actually *trees* (tries?) and are allocated like a persistent\ndata structure. Every new object starts with an empty map. As the program adds\nattributes, it follows the edges in the tree to find or create its new map.\n\nImage from CF Bolz-Tereick, CC BY 3.0.\n\nI recommend reading [the section on\nmaps](https://aosabook.org/en/500L/a-simple-object-model.html#instance-optimization)\nbecause it goes into a little more detail.\n\nKeeping all of that in mind, let’s take a look at CF’s Python implementation of\nobjects-with-maps and also of maps themselves:\n\n```\nclass Instance(Instance):\n    def __init__(self, cls: \"Class\"):\n        super().__init__(cls)\n        self.map = EMPTY_MAP\n        self.storage = []\n\n    def _read_dict(self, fieldname):\n        index = self.map.get_index(fieldname)\n        if index == -1:\n            return MISSING\n        return self.storage[index]\n\n    def _write_dict(self, fieldname, value):\n        index = self.map.get_index(fieldname)\n        if index != -1:\n            self.storage[index] = value\n        else:\n            new_map = self.map.next_map(fieldname)\n            self.storage.append(value)\n            self.map = new_map\n\nclass Map:\n    def __init__(self, attrs):\n        self.attrs = attrs\n        self.next_maps = {}\n\n    def get_index(self, fieldname):\n        return self.attrs.get(fieldname, -1)\n\n    def next_map(self, fieldname):\n        assert fieldname not in self.attrs\n        if fieldname in self.next_maps:\n            return self.next_maps[fieldname]\n        attrs = self.attrs.copy()\n        attrs[fieldname] = len(attrs)\n        result = self.next_maps[fieldname] = Map(attrs)\n        return result\n\nEMPTY_MAP = Map({})\n```\n\nYou may notice that the storage is not in-line, as I had originally promised.\nThis is because we don’t have that kind of object layout control in Python.\nAttribute storage, when implemented in a language such as C++ or Rust, is\ntypically in-line with the object header.\n\nIgnoring that, though, we can see that the common case still seems to read and\nwrite attributes using string names. What gives? Well, let’s use the index\nstorage as intended in an *inline cache*.\n\n## Inline caches\n\nLet’s start by looking at the string attribute API. We’ll make a point class and\nread the `x` attribute. We’ll keep the attribute name constant since this is\ngenerally fixed in the program: `obj.x` normally looks like some bytecode\n`LOAD_ATTRIBUTE, \"x\"` where the name `x` is embedded in the bytecode somehow.\n\nThis is important for the inline cache: varying the receiver is one thing but\nvarying the name being looked up adds a lot more complexity. It’s doable but\nirritating.\n\n```\nPoint = Class(name=\"Point\", base_class=OBJECT, fields={}, metaclass=TYPE)\nP = Instance(Point)\nP.write_attr(\"x\", 5)\nP.write_attr(\"y\", 6)\n\n# String API :(\nP.read_attr(\"x\")\n# Always looking up index :(\nP.read_attr(\"x\")\n# Every time :(\nP.read_attr(\"x\")\n```\n\nThen we will make a memory/speed trade-off. We will allocate space for the\naforementioned *expected map* and the *cached index* mentioned earlier. This\nlets us do a really fast check followed by an indexed load in the (hopefully)\ncommon case.\n\nThe logic is as follows:\n\n- If we have a matching map, great! Load from storage.\n- If we don’t, look up the value and index by string name.\n- And then cache the map / index combo for next time.\n\nIf the attribute is missing, we don’t (for now?) cache the negative lookup.\nHopefully this case is rare.\n\n```\nclass InlineCache:\n    def __init__(self):\n        self.map = None\n        self.idx = None\n\n    def read(self, obj, fieldname):\n        obj_map = obj.map\n        if obj_map is self.map:\n            return obj.storage[self.idx]\n        index = obj_map.get_index(fieldname)\n        if index == -1:\n            return MISSING\n        self.map = obj_map\n        self.idx = index\n        return obj.storage[index]\n\nIC = InlineCache()\n# Looked up by string :(\nIC.read(P, \"x\")\n# Map hit! Looked up by index! :)\nIC.read(P, \"x\")\n# Map hit! Looked up by index! :)\nIC.read(P, \"x\")\n```\n\nYou may note that I used `is` (pointer equality) instead of `==`\n(user-defined/deep equality). This is intentional! Because we are guaranteeing\nthe maps are immutable, we don’t need to check equality on the fields of the\nmap; just its pointer/numeric ID.\n\nThis `InlineCache` class is mostly just a “sketch” of what an inline cache\ndoes. It is an “interpreted IC”. This shape, if you make sure the fast path is\ninlined and the slow path is outlined, can help your interpreter a lot. It does\ndefinitely improve performance—I recommend benchmarking a silly little\nattribute lookup function—but most runtimes with inline caches also come with\nJIT compilers. These compilers try to remove the “interpreted”-ness of the\ninline cache implementation. See for example:\n\n- [Slava’s post](https://mrale.ph/blog/2015-01-11-whats-up-with-monomorphism.html) that\n  explains with IR sketches and JS-like pseudocode\n  - also [this older one](https://mrale.ph/blog/2012-06-03-explaining-js-vms-in-js-inline-caches.html)\n    which is similar\n- [Caio’s\n  post](https://caiolima.github.io/jsc/2020/03/12/jsc-inline-cache.html) that\n  explains what this looks like in JavaScriptCore with x86 asm\n- [Matthew’s post](https://www.mgaudet.ca/technical/2018/6/5/an-inline-cache-isnt-just-a-cache)\n  that explains what this looks like in pseudo-asm for speculative numeric addition\n  - mostly runtimes don’t do self-modifying asm ICs like this because of W^X\n    protection and also you need to flush the icache\n\nAnother thing to notice is that **order matters**. Adding attributes in different\norders (e.g. `a` then `b` vs `b` then `a`) will result in different shapes.\nAnother frequent cause of this is conditionally storing attributes in\ninitialization (e.g. `if cond: self.a = 1`).\n\nThis shape and cache behavior is not really visible to the application\nprogrammer except in performance regressions[2](#fn:attribute-order), where either\nyour inline caches will frequently miss or you will not have optimal\n(monomorphic) caches. This might not sound scary but the difference between a\ncache hit and a cache miss in a hot path could be 2x performance or more and is\nworth investigating in your application.\n\nAlso, **quantity matters**. VM implementors measured and determined that most\nobjects are small (<20 ish fields). You may find that putting too many fields\non an object leads to it getting penalized and put back in “hash table mode”.\nRuby, for example, has some fixed number of in-object instance variables\n(attributes) before its shape becomes “complex”.\n\nLast, notice that this cache is not specific to this object; any number of\n`Point` instances can get a cache hit. Actually, looking at it, an object need\nnot even be a `Point`…\n\n## Class vs shape\n\nWhat does it mean that two objects with different classes can have the same\nshape?\n\n```\nFoo = Class(name=\"Foo\", base_class=OBJECT, fields={}, metaclass=TYPE)\nF = Instance(Foo)\nF.write_attr(\"x\", 1)\nF.write_attr(\"y\", 2)\n\nBar = Class(name=\"Bar\", base_class=OBJECT, fields={}, metaclass=TYPE)\nB = Instance(Bar)\nB.write_attr(\"x\", 3)\nB.write_attr(\"y\", 4)\n\n# Same shape/map!\nassert F.map is B.map\n# But different class!\nassert F.cls != B.cls\n```\n\nFor one, it means that the inline cache may have a higher hit rate. Instances\nof subclasses that share code, for example, may still count as *monomorphic* in\nthe IC.\n\nIt also means that you can’t use the shape as a proxy for knowing about the\nclass. If you have something like `attr_reader` in Ruby, where the VM generates\na synthetic method just for looking up an instance variable, you need to do two\nthings:\n\n1. resolve the method based on the *object’s class* to find out that it’s `attr_reader`\n2. resolve the instance variable based on the *object’s shape*\n\nThis two-stage lookup adds both complexity and run-time overhead. If we\nspecialized maps so that having a different class implied having a different\nmap, we would be able to specialize that two-stage dispatch with just one field\nload.\n\nThis is kind of an interesting idea: what else can we specialize the map on?\n\n## Storage strategies\n\nPerhaps unsurprisingly, CF Bolz-Tereick reappears with Lukas Diekmann and\nLaurence Tratt in [Storage Strategies for Collections in Dynamically Typed\nLanguages](https://dl.acm.org/doi/pdf/10.1145/2544173.2509531) (PDF, 2013).\n\nThe main idea is that some facts about objects (properties, attributes, har\nhar) are both:\n\n- worth splitting/monomorphizing on (there is some kind of “payoff” for the\n  increase in branches and code size)\n- similarly sensitive to code location (int lists allocated here, float lists\n  over there)\n\nTheir treatment is for collections (for example, optimizing the common cases of\nan empty list, or a list of small integers, …). In addition to giving more\ninformation to the optimizer, it helps both reduce memory and reduce pointer\nchasing.\n\nThis kind of thinking extends beyond collections: do you ever have objects\nwhere an attribute is always a certain type? Maybe your `Point` class always\nhas floats in it? Consider, then, what I think of as CF’s follow-up paper, type\nfreezing: [Type Freezing: Exploiting Attribute Type Monomorphism in Tracing JIT\nCompilers](https://dl.acm.org/doi/pdf/10.1145/3368826.3377907) (PDF, 2020)\n\nThis paper changes object maps from `{name: offset}` to `{name: (offset,\ntype)}` to reduce guards. Then it goes further by even addressing nested\nattributes and their types. This nesting extension is mostly motivated by the\nmeta-circularity of the implementation and would have less impact for a\n“normal” runtime.\n\nSee also:\n\n- [An Efficient Implementation of SELF, a Dynamically-Typed Object-Oriented Language Based on Prototypes](https://bibliography.selflanguage.org/_static/implementation.pdf) (PDF, 1991)\n- [Optimizing Dynamically-Dispatched Calls with Run-Time Type Feedback](https://dl.acm.org/doi/pdf/10.1145/178243.178478) (PDF, 1994)\n- [An Object Storage Model for the Truffle Language Implementation Framework](https://dl.acm.org/doi/pdf/10.1145/2647508.2647517) (PDF, 2014)\n- [Extending Basic Block Versioning with Typed Object Shapes](https://arxiv.org/pdf/1507.02437) (PDF, 2015)\n- [Just-in-Time Data Structures](https://dl.acm.org/doi/pdf/10.1145/2814228.2814231) (PDF, 2015)\n- [Efficient and thread-safe objects for dynamically-typed languages](https://dl.acm.org/doi/10.1145/2983990.2984001) (PDF, 2016)\n- [Parallelization of dynamic languages: synchronizing built-in collections](https://dl.acm.org/doi/pdf/10.1145/3276478) (PDF, 2018)\n- [Profile Guided Offline Optimization of Hidden Class Graphs for JavaScript VMs in Embedded Systems](https://dl.acm.org/doi/pdf/10.1145/3563838.3567678) (PDF, 2022)\n- [Optimizations for Dynamic Collections](https://www.jot.fm/issues/issue_2025_01/a1.pdf) (PDF, 2025)\n\n## Class versioning\n\nYou can make even stronger bets about classes. See for example the [PyPy blog\nclass versioning\nsection](https://pypy.org/posts/2011/03/controlling-tracing-of-interpreter-with_21-6524148550848694588.html#versioning-of-classes).\n\n## Once more with ~~feeling~~ C++\n\nI felt like this blog post would not be complete without a manual memory layout\nrehashing of CF’s article. It’s all well and good to write the Python but what\ndo “real” runtimes look like?\n\nWell, they have compact object headers that contain the shape/map and some\nother flag bits. They allocate their attributes inline with an object. And they\nprobably also have an “overflow” slot for when their initial object sizing\nestimates were too small.\n\nSo I made a very very small runtime in C++ that allocates objects and adds\nattributes and has a shape system. It is probably *riddled* with bugs. But it\nsketches the kind of memory layouts that underpin Skybison, V8, JSC, and so on.\n\n[Take a look](https://gist.github.com/tekknolagi/caf8462b75ac8ba7e33f6ade06b3f914).\n\nIt doesn’t have the overflow slot but it’s a decent sketch of the underlying\nmodel.\n\n## And more\n\nI fear I am running out of steam on this blog post but there is more to talk\nabout. I originally wanted to go add object shapes to someone’s Lox interpreter\nand then add inline caches to the interpreter.\n\nPerhaps I will come back and update this post. Or perhaps you will write a\nsequel.\n\n## Thank you\n\nThank you to CF Bolz-Tereick, Nery Campusano, and Jacob Denbeaux for thoughtful\nfeedback on this post.\n\n1. Or the following equivalents…\n\n   ```\n   // Or\n   function C() {\n       this.a = 1;\n       this.b = 2;\n   }\n   // Or\n   class C {\n       constructor() {\n           this.a = 1;\n           this.b = 2;\n       }\n   }\n   ```\n\n   [↩](#fnref:js-synonyms)\n2. See [this Rails\n   PR](https://github.com/rails/rails/pull/57020) by Hartley to make shapes\n   more monomorphic. It was initially filed because ZJIT was not capable of\n   handling shape polymorphic reads, but it helps other JITs stay monomorphic\n   too. Also in this vein is [my PR to\n   optcarrot](https://github.com/mame/optcarrot/pull/59).\n\n   ZJIT now does much better with polymorphic shapes but this kind of stuff is\n   generally important across languages and runtimes. Enough for people to do\n   [large bodies](https://bernsteinbear.com/assets/img/optimization-coaching.pdf) (PDF) [of\n   research](https://bernsteinbear.com/assets/img/optimization-coaching-js.pdf) (PDF) about\n   intelligibly surfacing this kind of information to the application\n   programmer. [↩](#fnref:attribute-order)\n","body_html":"<h1 id=\"objects-attributes-maps-oh-my\">Objects, attributes, maps, oh my!</h1>\n<p>Or, <em>IC UC we all C for ICs</em>.</p>\n<p>In many dynamically typed programming languages, objects look like a big ol’\nmapping of names to values. The languages encourage this flexibility. Consider\nfor example Python:</p>\n<pre><code>class C:\n    def __init__(self):\n        self.a = 1\n        self.b = 2</code></pre>\n<p>Or Ruby:</p>\n<pre><code>class C\n  def initialize\n    @a = 1\n    @b = 2\n  end\nend</code></pre>\n<p>Or JavaScript<a href=\"#fn:js-synonyms\">1</a>:</p>\n<pre><code>let obj = {};\nobj.a = 1;\nobj.b = 2;</code></pre>\n<p>This might encourage the VM implementor to model object attributes using a hash\ntable. Hash tables, after all, are very good at mapping names to values.</p>\n<p>Here is, by way of example, a Python sketch of what this object model looks\nlike. Because it is a sketch, it uses <code>write_attr</code> and <code>read_attr</code> instead of\nthe much more common syntax that your programming language comes with.</p>\n<pre><code>MISSING = object()\n\nclass Instance:\n    def __init__(self):\n        # TODO: the rest of your object system\n        self.cls = ...\n        self._fields = {}\n\n    def read_attr(self, fieldname):\n        return self._fields.get(fieldname, MISSING)\n\n    def write_attr(self, fieldname, value):\n        self._fields[fieldname] = value\n\nobj = Instance()\nobj.write_attr(&quot;a&quot;, 1)\nobj.write_attr(&quot;b&quot;, 2)\nprint(obj.read_attr(&quot;a&quot;) + obj.read_attr(&quot;b&quot;))</code></pre>\n<p>This has its benefits:</p>\n<ul><li>it is straightforward to understand</li><li>… uh, well, maybe that’s it.</li></ul>\n<p>It also has its drawbacks:</p>\n<ul><li><p>it uses a hash table for a very common operation: while this is technically</p><p>O(1), hashing and probing are very slow</p></li><li><p>it has an indirection: jumping from an object to its separate hash table</p><p>field storage requires a lot of dependent memory loads. object attributes are\naccessed frequently and should therefore be stored in-line on the object\ninstead of another memory access (and potentially cache miss) away</p></li></ul>\n<p>This is a pretty good case where the hash table doesn&#39;t have its own internal\nfragmentation.</p>\n<p>This means that literally every operation <code>a.b</code> is doing hundreds or thousands\nof CPU instructions, including multiple dependent memory loads. This is *not\ngood* for performance.</p>\n<p>So runtime implementors tend to implement this thing called maps, or layouts,\nor shapes, or hidden classes, or structures, or probably a secret sixth name I\ndon’t know about, which optimizes reading, writing, and storage of attributes.</p>\n<p>But what does this shape code look like? How does it actually help performance?\nA lot of big production runtimes have pretty hairy code with domain-specific\nedge cases so they may not help get a handle on the basics. And a lot of blog\nposts, while excellent high-level overviews, are lacking in implementation. So\nhere we go once more, blogging where everyone else has already blogged before.\nWith any luck, you should be able to write a page of code and hold the whole\nthing in your head at once.</p>\n<h2 id=\"500-lines-or-less\">500 lines or less</h2>\n<p>I decided to once more give CF Bolz-Tereick’s <a href=\"https://aosabook.org/en/500L/a-simple-object-model.html\" rel=\"nofollow ugc noopener\">A Simple Object\nModel</a> the thorough\nread it deserves. In the past, I tried a couple times and bounced off of it\nbecause it models Python with reasonable fidelity and the Python object model\nis <em>really hairy</em>.</p>\n<p>But did I read it thoroughly this time? Well, no. This time I allowed myself to\nskim the class- and method-related parts and instead focus on the instance\nattribute and map parts. Inside CF’s post is the core of a very clean map\nsystem and I wanted to experiment with that, so I extracted it out.</p>\n<p>CF’s post starts off very similar to our own sketch, modeling objects as\nunordered maps of attributes. It also fills in some of the TODO stuff I left\nout earlier around classes. I’ll copy my reduced implementation of it here so\nyou can take a gander:</p>\n<pre><code># The base object model code:\n\nclass Base:\n    def __init__(self, cls, fields):\n        self.cls = cls\n        self._fields = fields\n\n    def read_attr(self, fieldname):\n        return self._read_dict(fieldname)\n\n    def write_attr(self, fieldname, value):\n        return self._write_dict(fieldname, value)\n\n    def _read_dict(self, fieldname):\n        return self._fields.get(fieldname, MISSING)\n\n    def _write_dict(self, fieldname, value):\n        self._fields[fieldname] = value\n\nMISSING = object()\n\nclass Instance(Base):\n    def __init__(self, cls: &quot;Class&quot;):\n        super().__init__(cls, {})\n\nclass Class(Base):\n    def __init__(self, name, base_class, fields, metaclass):\n        super().__init__(metaclass, fields)\n        self.name = name\n        self.base_class = base_class\n\nOBJECT = Class(name=&quot;object&quot;, base_class=None, fields={}, metaclass=None)\nTYPE = Class(name=&quot;type&quot;, base_class=OBJECT, fields={}, metaclass=None)\nTYPE.cls = TYPE\nOBJECT.cls = TYPE</code></pre>\n<p>Which you can use like this (test code also taken from CF’s post):</p>\n<pre><code>A = Class(name=&quot;A&quot;, base_class=OBJECT, fields={}, metaclass=TYPE)\nobj = Instance(A)\nobj.write_attr(&quot;a&quot;, 1)\nassert obj.read_attr(&quot;a&quot;) == 1\n\nobj.write_attr(&quot;b&quot;, 5)\nassert obj.read_attr(&quot;a&quot;) == 1\nassert obj.read_attr(&quot;b&quot;) == 5\n\nobj.write_attr(&quot;a&quot;, 2)\nassert obj.read_attr(&quot;a&quot;) == 2\nassert obj.read_attr(&quot;b&quot;) == 5</code></pre>\n<p>There’s a bit of delegation going on in the <code>Base</code> class because the post\nalready anticipates what we’re about to do: swap out the <code>Instance</code>\nimplementation for <code>_read_dict</code> and <code>_write_dict</code> for better performance.</p>\n<h2 id=\"cartography\">Cartography</h2>\n<p>OK, let’s add on this “map” data structure. The purpose of the map is to do\nthree things:</p>\n<ol><li><p>Get out of the business of dealing with strings and hashing in the fast</p><p> path. We would like to instead do operations on small integers.</p></li><li><p>Remove the indirection. We would like to store the attributes in-line in the</p><p> object instead of elsewhere in memory.</p></li><li>Be/have a stable identifier that describes a particular object layout.</li></ol>\n<p><strong>For the former</strong>, we can think of an object’s map field as an (database) index or\na write-through cache. It is responsible for reflecting on the layout of a\ngiven object:</p>\n<p>This model is <em>mostly</em> there...</p>\n<p>You can ask the map questions like “what attributes does this object have?” and\n“where is attribute <code>a</code> relative to the start of the object?”</p>\n<p>Just looking at the diagram, it may seem silly: after all, we have exchanged\none hash table for another. There’s one big difference, though, which is that\nin the shape model, we don’t <em>have</em> to always use the hash table. If we\nremember the offset since the last time we looked it up in the shape, we can\nre-use the offset. This mostly appears in an interpreter feature called an\ninline cache.</p>\n<p>This brings us to <strong>the second bit</strong>: if we have the offset into the object, we\ncan read right off of it. No need to go do a second load from another region of\nmemory. We have the object in hand and hopefully already in the cache.</p>\n<p>…At least, until the map <em>changes</em>. Then we can’t re-use the offset. Remember, these languages encourage this\nflexibility. An object could gain or lose an attribute at any time. What then?\nIdeally we have a very fast check that can tell us if the object’s in-memory\nlayout changed.</p>\n<p>This brings us to <strong>the third bit</strong>. We need to be able to rely on the map to\nbe immutable, up-to-date, and a stable identifier for a given in-memory layout.\nThis way we can do a very quick check (“is the map the same as last read?”)\nbefore a load.</p>\n<p>This means that when we add a new attribute to an object, we need to give it a\nnew map. Maps are actually <em>trees</em> (tries?) and are allocated like a persistent\ndata structure. Every new object starts with an empty map. As the program adds\nattributes, it follows the edges in the tree to find or create its new map.</p>\n<p>Image from CF Bolz-Tereick, CC BY 3.0.</p>\n<p>I recommend reading <a href=\"https://aosabook.org/en/500L/a-simple-object-model.html#instance-optimization\" rel=\"nofollow ugc noopener\">the section on\nmaps</a>\nbecause it goes into a little more detail.</p>\n<p>Keeping all of that in mind, let’s take a look at CF’s Python implementation of\nobjects-with-maps and also of maps themselves:</p>\n<pre><code>class Instance(Instance):\n    def __init__(self, cls: &quot;Class&quot;):\n        super().__init__(cls)\n        self.map = EMPTY_MAP\n        self.storage = []\n\n    def _read_dict(self, fieldname):\n        index = self.map.get_index(fieldname)\n        if index == -1:\n            return MISSING\n        return self.storage[index]\n\n    def _write_dict(self, fieldname, value):\n        index = self.map.get_index(fieldname)\n        if index != -1:\n            self.storage[index] = value\n        else:\n            new_map = self.map.next_map(fieldname)\n            self.storage.append(value)\n            self.map = new_map\n\nclass Map:\n    def __init__(self, attrs):\n        self.attrs = attrs\n        self.next_maps = {}\n\n    def get_index(self, fieldname):\n        return self.attrs.get(fieldname, -1)\n\n    def next_map(self, fieldname):\n        assert fieldname not in self.attrs\n        if fieldname in self.next_maps:\n            return self.next_maps[fieldname]\n        attrs = self.attrs.copy()\n        attrs[fieldname] = len(attrs)\n        result = self.next_maps[fieldname] = Map(attrs)\n        return result\n\nEMPTY_MAP = Map({})</code></pre>\n<p>You may notice that the storage is not in-line, as I had originally promised.\nThis is because we don’t have that kind of object layout control in Python.\nAttribute storage, when implemented in a language such as C++ or Rust, is\ntypically in-line with the object header.</p>\n<p>Ignoring that, though, we can see that the common case still seems to read and\nwrite attributes using string names. What gives? Well, let’s use the index\nstorage as intended in an <em>inline cache</em>.</p>\n<h2 id=\"inline-caches\">Inline caches</h2>\n<p>Let’s start by looking at the string attribute API. We’ll make a point class and\nread the <code>x</code> attribute. We’ll keep the attribute name constant since this is\ngenerally fixed in the program: <code>obj.x</code> normally looks like some bytecode\n<code>LOAD_ATTRIBUTE, &quot;x&quot;</code> where the name <code>x</code> is embedded in the bytecode somehow.</p>\n<p>This is important for the inline cache: varying the receiver is one thing but\nvarying the name being looked up adds a lot more complexity. It’s doable but\nirritating.</p>\n<pre><code>Point = Class(name=&quot;Point&quot;, base_class=OBJECT, fields={}, metaclass=TYPE)\nP = Instance(Point)\nP.write_attr(&quot;x&quot;, 5)\nP.write_attr(&quot;y&quot;, 6)\n\n# String API :(\nP.read_attr(&quot;x&quot;)\n# Always looking up index :(\nP.read_attr(&quot;x&quot;)\n# Every time :(\nP.read_attr(&quot;x&quot;)</code></pre>\n<p>Then we will make a memory/speed trade-off. We will allocate space for the\naforementioned <em>expected map</em> and the <em>cached index</em> mentioned earlier. This\nlets us do a really fast check followed by an indexed load in the (hopefully)\ncommon case.</p>\n<p>The logic is as follows:</p>\n<ul><li>If we have a matching map, great! Load from storage.</li><li>If we don’t, look up the value and index by string name.</li><li>And then cache the map / index combo for next time.</li></ul>\n<p>If the attribute is missing, we don’t (for now?) cache the negative lookup.\nHopefully this case is rare.</p>\n<pre><code>class InlineCache:\n    def __init__(self):\n        self.map = None\n        self.idx = None\n\n    def read(self, obj, fieldname):\n        obj_map = obj.map\n        if obj_map is self.map:\n            return obj.storage[self.idx]\n        index = obj_map.get_index(fieldname)\n        if index == -1:\n            return MISSING\n        self.map = obj_map\n        self.idx = index\n        return obj.storage[index]\n\nIC = InlineCache()\n# Looked up by string :(\nIC.read(P, &quot;x&quot;)\n# Map hit! Looked up by index! :)\nIC.read(P, &quot;x&quot;)\n# Map hit! Looked up by index! :)\nIC.read(P, &quot;x&quot;)</code></pre>\n<p>You may note that I used <code>is</code> (pointer equality) instead of <code>==</code>\n(user-defined/deep equality). This is intentional! Because we are guaranteeing\nthe maps are immutable, we don’t need to check equality on the fields of the\nmap; just its pointer/numeric ID.</p>\n<p>This <code>InlineCache</code> class is mostly just a “sketch” of what an inline cache\ndoes. It is an “interpreted IC”. This shape, if you make sure the fast path is\ninlined and the slow path is outlined, can help your interpreter a lot. It does\ndefinitely improve performance—I recommend benchmarking a silly little\nattribute lookup function—but most runtimes with inline caches also come with\nJIT compilers. These compilers try to remove the “interpreted”-ness of the\ninline cache implementation. See for example:</p>\n<ul><li><p><a href=\"https://mrale.ph/blog/2015-01-11-whats-up-with-monomorphism.html\" rel=\"nofollow ugc noopener\">Slava’s post</a> that</p><p>explains with IR sketches and JS-like pseudocode</p>\n<ul><li><p>also <a href=\"https://mrale.ph/blog/2012-06-03-explaining-js-vms-in-js-inline-caches.html\" rel=\"nofollow ugc noopener\">this older one</a></p><p>which is similar</p></li></ul></li><li><p>[Caio’s</p><p>post](<a href=\"https://caiolima.github.io/jsc/2020/03/12/jsc-inline-cache.html\" rel=\"nofollow ugc noopener\">https://caiolima.github.io/jsc/2020/03/12/jsc-inline-cache.html</a>) that\nexplains what this looks like in JavaScriptCore with x86 asm</p></li><li><p><a href=\"https://www.mgaudet.ca/technical/2018/6/5/an-inline-cache-isnt-just-a-cache\" rel=\"nofollow ugc noopener\">Matthew’s post</a></p><p>that explains what this looks like in pseudo-asm for speculative numeric addition</p>\n<ul><li><p>mostly runtimes don’t do self-modifying asm ICs like this because of W^X</p><p>protection and also you need to flush the icache</p></li></ul></li></ul>\n<p>Another thing to notice is that <strong>order matters</strong>. Adding attributes in different\norders (e.g. <code>a</code> then <code>b</code> vs <code>b</code> then <code>a</code>) will result in different shapes.\nAnother frequent cause of this is conditionally storing attributes in\ninitialization (e.g. <code>if cond: self.a = 1</code>).</p>\n<p>This shape and cache behavior is not really visible to the application\nprogrammer except in performance regressions<a href=\"#fn:attribute-order\">2</a>, where either\nyour inline caches will frequently miss or you will not have optimal\n(monomorphic) caches. This might not sound scary but the difference between a\ncache hit and a cache miss in a hot path could be 2x performance or more and is\nworth investigating in your application.</p>\n<p>Also, <strong>quantity matters</strong>. VM implementors measured and determined that most\nobjects are small (&lt;20 ish fields). You may find that putting too many fields\non an object leads to it getting penalized and put back in “hash table mode”.\nRuby, for example, has some fixed number of in-object instance variables\n(attributes) before its shape becomes “complex”.</p>\n<p>Last, notice that this cache is not specific to this object; any number of\n<code>Point</code> instances can get a cache hit. Actually, looking at it, an object need\nnot even be a <code>Point</code>…</p>\n<h2 id=\"class-vs-shape\">Class vs shape</h2>\n<p>What does it mean that two objects with different classes can have the same\nshape?</p>\n<pre><code>Foo = Class(name=&quot;Foo&quot;, base_class=OBJECT, fields={}, metaclass=TYPE)\nF = Instance(Foo)\nF.write_attr(&quot;x&quot;, 1)\nF.write_attr(&quot;y&quot;, 2)\n\nBar = Class(name=&quot;Bar&quot;, base_class=OBJECT, fields={}, metaclass=TYPE)\nB = Instance(Bar)\nB.write_attr(&quot;x&quot;, 3)\nB.write_attr(&quot;y&quot;, 4)\n\n# Same shape/map!\nassert F.map is B.map\n# But different class!\nassert F.cls != B.cls</code></pre>\n<p>For one, it means that the inline cache may have a higher hit rate. Instances\nof subclasses that share code, for example, may still count as <em>monomorphic</em> in\nthe IC.</p>\n<p>It also means that you can’t use the shape as a proxy for knowing about the\nclass. If you have something like <code>attr_reader</code> in Ruby, where the VM generates\na synthetic method just for looking up an instance variable, you need to do two\nthings:</p>\n<ol><li>resolve the method based on the <em>object’s class</em> to find out that it’s <code>attr_reader</code></li><li>resolve the instance variable based on the <em>object’s shape</em></li></ol>\n<p>This two-stage lookup adds both complexity and run-time overhead. If we\nspecialized maps so that having a different class implied having a different\nmap, we would be able to specialize that two-stage dispatch with just one field\nload.</p>\n<p>This is kind of an interesting idea: what else can we specialize the map on?</p>\n<h2 id=\"storage-strategies\">Storage strategies</h2>\n<p>Perhaps unsurprisingly, CF Bolz-Tereick reappears with Lukas Diekmann and\nLaurence Tratt in <a href=\"https://dl.acm.org/doi/pdf/10.1145/2544173.2509531\" rel=\"nofollow ugc noopener\">Storage Strategies for Collections in Dynamically Typed\nLanguages</a> (PDF, 2013).</p>\n<p>The main idea is that some facts about objects (properties, attributes, har\nhar) are both:</p>\n<ul><li><p>worth splitting/monomorphizing on (there is some kind of “payoff” for the</p><p>increase in branches and code size)</p></li><li><p>similarly sensitive to code location (int lists allocated here, float lists</p><p>over there)</p></li></ul>\n<p>Their treatment is for collections (for example, optimizing the common cases of\nan empty list, or a list of small integers, …). In addition to giving more\ninformation to the optimizer, it helps both reduce memory and reduce pointer\nchasing.</p>\n<p>This kind of thinking extends beyond collections: do you ever have objects\nwhere an attribute is always a certain type? Maybe your <code>Point</code> class always\nhas floats in it? Consider, then, what I think of as CF’s follow-up paper, type\nfreezing: <a href=\"https://dl.acm.org/doi/pdf/10.1145/3368826.3377907\" rel=\"nofollow ugc noopener\">Type Freezing: Exploiting Attribute Type Monomorphism in Tracing JIT\nCompilers</a> (PDF, 2020)</p>\n<p>This paper changes object maps from <code>{name: offset}</code> to <code>{name: (offset,\ntype)}</code> to reduce guards. Then it goes further by even addressing nested\nattributes and their types. This nesting extension is mostly motivated by the\nmeta-circularity of the implementation and would have less impact for a\n“normal” runtime.</p>\n<p>See also:</p>\n<ul><li><a href=\"https://bibliography.selflanguage.org/_static/implementation.pdf\" rel=\"nofollow ugc noopener\">An Efficient Implementation of SELF, a Dynamically-Typed Object-Oriented Language Based on Prototypes</a> (PDF, 1991)</li><li><a href=\"https://dl.acm.org/doi/pdf/10.1145/178243.178478\" rel=\"nofollow ugc noopener\">Optimizing Dynamically-Dispatched Calls with Run-Time Type Feedback</a> (PDF, 1994)</li><li><a href=\"https://dl.acm.org/doi/pdf/10.1145/2647508.2647517\" rel=\"nofollow ugc noopener\">An Object Storage Model for the Truffle Language Implementation Framework</a> (PDF, 2014)</li><li><a href=\"https://arxiv.org/pdf/1507.02437\" rel=\"nofollow ugc noopener\">Extending Basic Block Versioning with Typed Object Shapes</a> (PDF, 2015)</li><li><a href=\"https://dl.acm.org/doi/pdf/10.1145/2814228.2814231\" rel=\"nofollow ugc noopener\">Just-in-Time Data Structures</a> (PDF, 2015)</li><li><a href=\"https://dl.acm.org/doi/10.1145/2983990.2984001\" rel=\"nofollow ugc noopener\">Efficient and thread-safe objects for dynamically-typed languages</a> (PDF, 2016)</li><li><a href=\"https://dl.acm.org/doi/pdf/10.1145/3276478\" rel=\"nofollow ugc noopener\">Parallelization of dynamic languages: synchronizing built-in collections</a> (PDF, 2018)</li><li><a href=\"https://dl.acm.org/doi/pdf/10.1145/3563838.3567678\" rel=\"nofollow ugc noopener\">Profile Guided Offline Optimization of Hidden Class Graphs for JavaScript VMs in Embedded Systems</a> (PDF, 2022)</li><li><a href=\"https://www.jot.fm/issues/issue_2025_01/a1.pdf\" rel=\"nofollow ugc noopener\">Optimizations for Dynamic Collections</a> (PDF, 2025)</li></ul>\n<h2 id=\"class-versioning\">Class versioning</h2>\n<p>You can make even stronger bets about classes. See for example the <a href=\"https://pypy.org/posts/2011/03/controlling-tracing-of-interpreter-with_21-6524148550848694588.html#versioning-of-classes\" rel=\"nofollow ugc noopener\">PyPy blog\nclass versioning\nsection</a>.</p>\n<h2 id=\"once-more-with-feeling-c\">Once more with <del>feeling</del> C++</h2>\n<p>I felt like this blog post would not be complete without a manual memory layout\nrehashing of CF’s article. It’s all well and good to write the Python but what\ndo “real” runtimes look like?</p>\n<p>Well, they have compact object headers that contain the shape/map and some\nother flag bits. They allocate their attributes inline with an object. And they\nprobably also have an “overflow” slot for when their initial object sizing\nestimates were too small.</p>\n<p>So I made a very very small runtime in C++ that allocates objects and adds\nattributes and has a shape system. It is probably <em>riddled</em> with bugs. But it\nsketches the kind of memory layouts that underpin Skybison, V8, JSC, and so on.</p>\n<p><a href=\"https://gist.github.com/tekknolagi/caf8462b75ac8ba7e33f6ade06b3f914\" rel=\"nofollow ugc noopener\">Take a look</a>.</p>\n<p>It doesn’t have the overflow slot but it’s a decent sketch of the underlying\nmodel.</p>\n<h2 id=\"and-more\">And more</h2>\n<p>I fear I am running out of steam on this blog post but there is more to talk\nabout. I originally wanted to go add object shapes to someone’s Lox interpreter\nand then add inline caches to the interpreter.</p>\n<p>Perhaps I will come back and update this post. Or perhaps you will write a\nsequel.</p>\n<h2 id=\"thank-you\">Thank you</h2>\n<p>Thank you to CF Bolz-Tereick, Nery Campusano, and Jacob Denbeaux for thoughtful\nfeedback on this post.</p>\n<ol><li><p>Or the following equivalents…</p><p> <code>\n // Or\n function C() {\n     this.a = 1;\n     this.b = 2;\n }\n // Or\n class C {\n     constructor() {\n         this.a = 1;\n         this.b = 2;\n     }\n }\n </code></p>\n<p> <a href=\"#fnref:js-synonyms\">↩</a></p></li><li><p>See [this Rails</p><p> PR](<a href=\"https://github.com/rails/rails/pull/57020\" rel=\"nofollow ugc noopener\">https://github.com/rails/rails/pull/57020</a>) by Hartley to make shapes\n more monomorphic. It was initially filed because ZJIT was not capable of\n handling shape polymorphic reads, but it helps other JITs stay monomorphic\n too. Also in this vein is <a href=\"https://github.com/mame/optcarrot/pull/59\" rel=\"nofollow ugc noopener\">my PR to\n optcarrot</a>.</p>\n<p> ZJIT now does much better with polymorphic shapes but this kind of stuff is\n generally important across languages and runtimes. Enough for people to do\n <a href=\"https://bernsteinbear.com/assets/img/optimization-coaching.pdf\" rel=\"nofollow ugc noopener\">large bodies</a> (PDF) <a href=\"https://bernsteinbear.com/assets/img/optimization-coaching-js.pdf\" rel=\"nofollow ugc noopener\">of\n research</a> (PDF) about\n intelligibly surfacing this kind of information to the application\n programmer. <a href=\"#fnref:attribute-order\">↩</a></p></li></ol>","headings":[{"level":1,"text":"Objects, attributes, maps, oh my!","id":"objects-attributes-maps-oh-my"},{"level":2,"text":"500 lines or less","id":"500-lines-or-less"},{"level":2,"text":"Cartography","id":"cartography"},{"level":2,"text":"Inline caches","id":"inline-caches"},{"level":2,"text":"Class vs shape","id":"class-vs-shape"},{"level":2,"text":"Storage strategies","id":"storage-strategies"},{"level":2,"text":"Class versioning","id":"class-versioning"},{"level":2,"text":"Once more with ~~feeling~~ C++","id":"once-more-with-feeling-c"},{"level":2,"text":"And more","id":"and-more"},{"level":2,"text":"Thank you","id":"thank-you"}]}}