What is gained and lost with 63-bit integers? (2014)
blog.janestreet.com
blog.janestreet.com
lea is a pretty common instruction even in non-OCaml code so this is annoying. I wonder if this has changed since 2014?
$ objdump -d /usr/bin/ls | grep '\blea\b' | wc -l
908
$ objdump -d /usr/bin/ls | wc -l
21398
4.2% of all instructions, and that's an underestimate because the raw objdump -d output contains blank lines, nops, etc.For an OCaml 4.14 program (/usr/bin/virt-v2v), I got 38886 / 432937 which is 9.0%.
For OCaml 5 using the flambda optimizer, 36531 / 480357 = 7.6%
Good question. Probably not. LLVM reduced the number of LEAs in 2017. https://reviews.llvm.org/D32277
Also, Golang is gradually reducing the number of slow LEAs. https://github.com/golang/go/issues/21735
> grep '\blea\b' | wc -l
This maybe isn't sophisticated enough? The other links seem to explain two operand LEA isn't so problematic.
https://github.com/ocaml/ocaml/pull/8531 https://github.com/ocaml/ocaml/issues/9850
It did not occur to me to use the low (LS) bit as the tag. I wonder if the ocaml approach is faster or slower. We're not doing garbage collection, so the cool "pointers are free" aspect isn't relevant.
We're also even slower than the ocaml-style tagging for a different reason: we force atomic loads & stores because our values are by definition read/write from any number of threads.
[ EDIT: if anyone wants to tell me that my coding is crap and I could get a 4x speedup by doing <X>, please do: https://github.com/Ardour/ardour/blob/master/libs/pbd/pbd/in... ]
EDIT: I think it may be related to this (now fixed) gcc bug:https://gcc.gnu.org/bugzilla/show_bug.cgi?id=65147
You’re making a really good case for flagging comments, which I almost never do.
Why on earth I would have used 2's complement conventions for what is truly just a boolean/bistate flag is far from obvious, and it ought to be (better) commented.
One thing I would say in my own defense is that one aspect of remote-distributed cooperative development is that sometimes the conversations you have with others (e.g. on IRC, or mattermost or even discord if that's your thing) can sometimes feel as if you've just "written the documentation", even though it will generally vanish into the ether. I think that is what happened here. I remember discussing it with others on our IRC dev channel, but I don't keep logs.
You rock, literally!
V8 uses 1 for pointers, 0 for integers - you're usually loading from constant offsets from pointers anyway, so that mostly folds away nicely. Then:
x + y is translated to CPU instructions x + y
x * y is translated to CPU instructions (x >> 1) * y
x / y is translated to CPU instructions (((x >> 1) / (y >> 1)) << 1)
x lsl y is translated to CPU instructions (x << (y >> 1))
On ARM, "ldr x0, [x1]" just becomes "ldur x0, [x1, #-1]" - same size, same performance (at least on the Apple M1). If you have an array in a structure "add x0, x1, #16 ; ldr x0, [x0, x2, lsl #3]" becomes "add x0, x1, #15 ; ldr x0, [x0, x2, lsl #3]".
The only place I can think of penalties are pointers directly to arrays: "ldr x0, [x0, x1, lsl #3]" becomes "sub x0, x0, #1 ; ldr x0, [x0, x1, lsl #3]". Or loads from large offsets - ldur can only reach 256 bytes, whereas the aligned ldr can reach 32760 bytes. In either case the penalty is only one SUB operation.
Off-tangent: virtual pointers on AMD64 are effectively signed numbers and yet most low-level intro-level programming books keep saying that "pointers are unsigned ints because it doesn't make sense for pointers to be negative, bla-bla". Hell, I believe xv6 book has this passage and the passage about pointers' upper-bits on AMD64 being either all 1 or 0 on the same page!
I sometimes use "width-1" integer types in Zig because Zig doesn't allow to assign an unsigned integer to a signed integer of the same width (e.g. assigning an u8 to an i8 is illegal without a cast, but assigning u7 to i8 is fine).
type t = One (* represented as int 0 (0b01) *)
| Two (* represented as int 1 (0b11) *)
| Pair of t * t (* boxed (ie. pointer) *)
The representation used by OCaml is quite subtle. There's a distinction between the very rich types known at compile time which are almost all erased, and the little bit of information that is needed at runtime mainly by the garbage collector.Here are some links to how it all works:
https://dev.realworldocaml.org/runtime-memory-layout.html https://rwmj.wordpress.com/2009/08/04/ocaml-internals/ https://web.archive.org/web/20210412024831/http://caml.inria...
Great episode, I highly recommend it to all interested in language runtime design.
https://signals-threads.simplecast.com/episodes/memory-manag...
If a language needs to rely on pointer tags for GC, avoid that language.
An implementation can choose to implement ‘small’ objects as tagged pointers, and use a pointer to an ‘integer’ object for objects it cannot ’hide’ inside a pointer.
A 63-bit integer would give you 145 years of range at nanosecond resolution; if you chose millisecond precision instead, you could accurately timestamp the death of the last mammoth.
+------------+------------+------------+------------+ - - -
| header | data[0] | data[1] | data[2] |
+------------+------------+------------+------------+ - - -
^
pointer
(The real data part can be all kinds of things including stuff opaque to the GC like strings).I'm still a bit unconvinced that swapping the sense of pointers vs ints is useful for OCaml, as it would be a massive change with no current evidence base. It seems better to me to work on better compiler optimizations so that integers can be kept in registers with their true value more often.
In SBCL, this also allows cons cells to have their own offset, so car and cdr are implemented with one instruction and are safe (that is, throw an exception if applied to something they don't work on) even in code compiled at (safety 0).
Now just waiting for parallel GC in SBCL. ;) Well, also waiting for some other things, I will admit.
It is? By default x86 will happily do unaligned accesses, and I'm pretty sure SBCL doesn't change that. Not sure about other instruction sets.
SBCL also uses double-word alignment and 4-bit tags, so you could CDR (expecting lowtag #b0111) a structure object (lowtag #b1111).
Might it be better to have a tagged 64-bit type where, if the type is an int, one half of the value is a 32-bit int? You could then do arithmetic with 32-bit values just with bit-masking, or by using instructions that operate on half of a register, rather than having to shift. If the result of a calculation exceeds 32-bits (checked with carry flag) then you can box that into a 64-bit (or bignum?) int instead?
If you have a record type, or anything else, that'll compile to a pointer and an object in memory, comparable to e.g. Python or Java objects.
Types like booleans are converted to integers by the compiler, similarly to C(++) (though there's no implicit conversion on the language level).
This is the "float boxing" problem - floats cost an extra pointer chase in most cases. The 63-bit int is an optimization that avoids needing the extra indirection for ints. You can still have 64-bit ints, but they aren't the default, and they cost this extra pointer chase.
You have to wonder what fraction of the lost efficiency can be clawed back through such techniques…
Both languages I mentioned use regular 8/16/32/64 bit size values, which is why I asked.
F# doesn't do that and always stores a separate tag field for sum types. This is marginally less efficient, but doesn't make interop with 99% of languages and data formats in existence awkward.
This slows down object access as detecting and stripping the NaN tag requires few CPU instructions. Plus it assumes that pointers only have 48 bits with rest are zeros (true for AMD, ARM and Intel) or at least have fixed values (can be arranged on more exotic CPUs). But that does not require to box numbers greatly reducing GC pressure.
Except when it isn't: https://en.wikipedia.org/wiki/Intel_5-level_paging
That's not something you're likely to run into on consumer hardware, but with JS being used on the server I wonder if/when JS engines will need to deal with that.
I guess you could have a lot of files mmaped though?
https://stackoverflow.com/questions/63550957/why-does-v8-use...
Yes other engines use NaN boxing, but not all engines do.
This is why I prefer the NaN-boxing approach, where you can hold 64-bit float values and 48-bit pointers and integers as values in a 64-bit register. 48-bits is sufficient for pointers because few CPUs support greater than 48-bit addressing.
When you need full 64-bit integers, chances are you need more than one of them. Have one of your NaN-box tags indicate that a memory address is a vector of 64-bit integers and load them into a vector register.
It should be obvious. A 64-bit word can't represent 63-bit integers, 63-bit pointers and 64-bit floats without overlapping/ambiguous representation.
You can represent 64-bit floats, 48-bit pointers and 48-bit integers (signed and unsigned) in a 64-bit word without any overlap though, and you can avoid the pointer dereference for FP operations, instead only needing to perform a check for NaN. Other types need unboxing which can be done in ~2 cycles, without any dereferencing.
As an optimization, records whose fields all have static type float are represented as arrays of floating-point numbers, with tag Double_array_tag. (See the section below on arrays.)
[...]
Arrays of floating-point numbers (type float array) have a special, unboxed, more efficient representation. These arrays are represented by pointers to blocks with tag Double_array_tag.With NaN-boxing you would do this for Int64s. They would need boxing when you need a single value, but you can have one or more tags for vectors of them, in which case the elements can be stored unboxed.
My argument is you would probably use FP64 more frequently than Int64s. For most common operations involving int, Int48 would suffice. In the cases where you need full Int64 (cryptography, serialization, etc), you commonly need vectors of Int64 anyway.
So why do CPUs not have special instructions to accelerate common OOP GC language operations?
You could have math operations that did the tagged ints in a single cycle.
On the compiler side, in languages that need to support closures anyway, could they just give the data structure for a closure an extra scratchpad for numeric values that are raw and untagged with no extra stuff required? You know they're not a pointer because of where they are, and the GC doesn't need to look at them at all, they go away when the function does.
ARM kind of tried with Jazelle and ThumbEE and it turns out not to be that useful. It's much more economical to make the CPU as a whole faster rather than burning up precious die space for hardware that's only used some of the time, and only has marginal benefits.
There's also the human factor that if you actually want a JIT/interpreter to use your special sauce you will probably need to patch that runtime yourself, because it's not likely that the developers are just going to rip up their code generator unless it's really simple to do or presents massive gains.
> Could they just give the data structure for a closure an extra scratchpad for numeric values that are raw and untagged with no extra stuff required?
This is called "unboxing" and pretty much every optimizing runtime will do it in some way. But saving in memory and looking it up later is a lot more expensive than arithmetic in general.
Lea became very slow in recent Intel cpus? News to me. Hard to imagine them shipping such a cpu considering x86 compilers pattern match to Lea hella aggressively.
Weird to show the more complex arithmetic codegen as a downside of not allocating int boxes. Having to shift or add (or both) a lot is way cheaper than asking the GC for memory or dereferencing a pointer to a box.
Article is from 2014. The "recent" CPUs they are discussing is the Sandy Bridge architecture launched in 2011.
See this discussion about the performance impact of LEA in Sandy Bridge: https://github.com/ocaml/ocaml/issues/6125#issuecomment-4729...
Do they not monomorphize generics?
Go has this problem too but they choose to use a "conservative collector" which will just assume that if something is a word that contains something that happens to be a valid location in memory it considers that location to be alive even if it's actually garbage.
The main drawback is that if you're not sure if something is really a pointer, you cannot relocate objects and compact the heap. Ocaml has a generational collector with a nursery implemented with a semi space collector. A semi space collector performs a collection which involves moving live objects and leaving the garbage behind. This is very efficient because young objects are less likely to survive and thus the amount of work is proportional to the amount of surviving objects and not the garbage.
[1] possibly static and indexed by the instruction pointer, not unlike DWARF unwind info.
For those reasons, I stick to C++. With modern ABIs there is no difference between a primitive of size N and a class of size N.
It has to do that 63 bit integers, like Lua and V8 JS, they are much faster, because no malloc() or pointer indirection is required to do 1234+5678. It's just a few CPU instruction. In Python, it's like 100.
struct {
enum type;
union { int; double; long; void\*};
}
and you don't need to box anything. no extra allocation for primary data types. no de-referencing of pointers unless the data is larger than the union size. you can pass this struct from function to function by value, as like it's an int64 or double.