DEV Community

Cover image for DynamoDB changed how it bills a vector read
Martin Hicks
Martin Hicks

Posted on Originally published at martinhicks.dev

DynamoDB changed how it bills a vector read

On 9th October I pinned a test in Parity Suite that said a read of a particular DynamoDB item costs 2 read units. On 22nd October, while getting the 3.7.0 release ready, the same read against real DynamoDB cost 1. Same item, same request. I can't find anything from AWS saying it changed.

The item has a vector in it, and vectors are the one place I know of where DynamoDB keeps two sizes for an item.

Two sizes for one item

A vector in DynamoDB is a list of numbers. Stored, each number is sized the way DynamoDB sizes any number: roughly one byte per two significant digits, plus one. A 38-digit integer is 20 bytes. The list adds 3 bytes, and 1 more per element.

Billed, it's different. Once a vector index reads the attribute, a write charges it at its name plus 4 bytes per dimension, whatever numbers are in it. That's the size of a 32-bit float, which is presumably what the index holds. DescribeTable's TableSizeBytes uses the same figure, and AWS's pricing page treats a 768-dimension vector as 3,072 bytes when it works out the base table's cost.

So an item can be 4KB as far as billing is concerned and over 4KB on disk. The question is which of the two a read goes by.

The read

The test builds exactly that item: six dimensions of 38-digit integers, padded so it's 4,096 bytes billed and 4,201 bytes stored. Then it does a strongly consistent GetItem and asks for the consumed capacity.

A read unit covers 4KB, so the answer tells you which size the read used. 2 units means the stored size, 1 means the billed size.

On 9th October, in eu-west-2, it was 2. That came out of an evening of bisecting the boundaries with a padding string, so every figure was exact to the byte. Writes went by the billed size, in eu-west-2 and us-east-1. Every read I tried went by the stored size: this one, plus GetItem and Query either side of 8KB, strongly and eventually consistent. So did the 400KB item limit. I wrote the tests to say so, and the read test passed against eu-west-2 that night.

On 22nd October the same test, unchanged, failed in eu-west-2 with 1. I ran it in every region, and it was 1 in all 32 that offer vector search. The 400KB limit hasn't moved. An item whose numbers take it over 400KB is still refused, even when its billed size is well under.

My first thought was that I'd got it wrong on the 9th, because I'd changed Dynoxide to match in a hurry that same evening. But the raw responses from that capture are saved, and they say 2. It's the same item, the same request and the same region, and the answer has changed.

What I think happened

I can't see inside DynamoDB, so this part is a guess, and I could be wrong about it. When vector search launched, writes and storage were metered on the billed size, but reads still went through the ordinary item-size calculation every other item uses. That made a read of a vector item cost more than the pricing page implies, and somebody brought it into line. The 400KB limit staying put fits that too. It's a limit on what's actually stored rather than a billing figure, so there's no reason for it to follow.

What I can't tell you is when. The suite only runs against real AWS on a schedule or when a change lands on main, never on a pull request, because every run shares one AWS account and costs money. This test sat on a branch from 9th October to 22nd October, so nothing asked DynamoDB the question in between. Some time in those two weeks is as close as I can get.

I also only re-measured a consistent GetItem. Eventually consistent reads, BatchGetItem, TransactGetItems, Query, Scan and PartiQL reads might have moved with it, or might not. They're next to capture.

Where the stored size still matters

If you're paying for reads, the change works in your favour, though for most vector items it won't make a visible difference. Where the stored size still counts is the 400KB limit, and the gap between the two sizes depends on how many digits your numbers carry.

A float32 written out to 9 significant digits is about 6 bytes stored, against 4 billed. But if your embedding comes back as a Float32Array in JavaScript and you write the values as plain numbers, each one is widened to a double first, and 0.1 becomes 0.10000000149011612. That's 17 significant digits, about 10 bytes stored, plus 1 for the list element. A 768-dimension vector written that way is around 8,450 bytes on disk and 3,072 on the bill.

With one vector per item that's nowhere near 400KB. With several vectors per item, or a large document sitting next to one, the stored size is the one you'll hit.

Dynoxide is now wrong about this

Dynoxide copied the 9th October behaviour, because that's what DynamoDB did when I measured it. It still charges 2 units for that read, so as of 3.7.0 it fails a test it used to pass. Floci and ExtendDB's SQLite build fail it the same way. The fix is tracked in #264, and it waits on measuring the other read paths first, so I don't move one and leave the rest behind.

The rest of 3.7.0

The vector test is one of 412 new ones. The suite went from 1,266 tests to 1,678, mostly on rejection paths and the order in which errors are reported, where an emulator giving the wrong answer used to score a pass because nothing checked.

That took a letter off most of the board. ExtendDB, LocalStack and Ministack go from B to C, DynamoDB Local and Dynalite from C to D, and Dynoxide's browser build from A to B. Dynoxide's native build stays an A, though its divergence goes from 0.8% to 2.2%. Floci went the other way, from C to B, with its Tier 2 divergence down from 27.5% to 6.2% on a newer build.

The board is at paritysuite.org, and the changelog lists everything 3.7.0 added.

Top comments (0)