This looks like a case of bottlenecks.
When you have a sequence of steps that depend on each other, like...
- uploading data buffer to the GPU
- issuing the draw call
- generating the vertices
- transforming those vertices to the viewport
- rasterizing the resulting triangles
- interpolating values for each fragment
- rendering each fragment
- writing the results to the frame buffer
...then the whole pipeline cannot proceed faster than the most time-consuming operation in the sequences. In chemistry this is called the "rate-determining step," while in programming and graphics we call it a "bottleneck".
Making the operations before or after the bottleneck faster won't substantially speed up the whole, because your time is still dominated by the bottlenecked step. (You can sometimes slightly improve the latency this way, but you don't change the total throughput)
One important bottleneck in graphics is "fill rate". There's a limit to how fast the GPU can write the results of an operation out to the frame buffer. You'll often hit this one if you have very simple shaders that draw over large parts of a high-resolution output target, especially if you draw over them again and again (eg. layering lots of translucent blended sprites)
Another common bottleneck is texture bandwidth. There's a limit to how fast the GPU can read, decode, and interpolate texture samples. You'll often hit this one if you sample a lot of large textures in random patterns that make it hard for the texture cache to re-use recent samples.
For a use case like yours, where building and uploading a few thousand quads really isn't very heavy lifting by modern GPU standards, and you're not doing any fancy calculations in your shader that might slow it down, then either of those two bottlenecks above might end up being the slowest part of your pipeline.
Making the generation/upload step faster won't necessarily give you an observable improvement in framerate, because it's not what was costing most of the time in the first place.
If that's the case, then scaling up your problem size by increasing the number of tiles (while keeping the textures and output resolution the same) will tend to make the cost of that generation/upload higher while keeping the texturing/fill rate costs about the same. Eventually you'll hit a tipping point, where now the generation/upload is the slow part.
At that stage, you may start to see a clearer comparison between your CPU and Geometry Shader methods, without the rendering costs common to both solutions muddying the picture. From your comments, it sounds like that's what happened. 🙂